Add 'src/jexer/' from commit 'cf01c92f5809a0732409e280fb0f32f27393618d'
authorNiki Roo <niki@nikiroo.be>
Thu, 24 Oct 2019 07:15:15 +0000 (09:15 +0200)
committerNiki Roo <niki@nikiroo.be>
Thu, 24 Oct 2019 07:15:15 +0000 (09:15 +0200)
git-subtree-dir: src/jexer
git-subtree-mainline: b709b36e17eb8807819e51297bb398ef28ece52d
git-subtree-split: cf01c92f5809a0732409e280fb0f32f27393618d

166 files changed:
1  2 
src/jexer/.classpath
src/jexer/.gitignore
src/jexer/.project
src/jexer/Scrollable.java
src/jexer/TAction.java
src/jexer/TApplication.java
src/jexer/TApplication.properties
src/jexer/TButton.java
src/jexer/TCalendar.java
src/jexer/TCheckBox.java
src/jexer/TComboBox.java
src/jexer/TCommand.java
src/jexer/TDesktop.java
src/jexer/TDirectoryList.java
src/jexer/TEditColorThemeWindow.java
src/jexer/TEditColorThemeWindow.properties
src/jexer/TEditorWidget.java
src/jexer/TEditorWindow.java
src/jexer/TEditorWindow.properties
src/jexer/TExceptionDialog.java
src/jexer/TExceptionDialog.properties
src/jexer/TField.java
src/jexer/TFileOpenBox.java
src/jexer/TFileOpenBox.properties
src/jexer/TFontChooserWindow.java
src/jexer/TFontChooserWindow.properties
src/jexer/THScroller.java
src/jexer/TImage.java
src/jexer/TImageWindow.java
src/jexer/TImageWindow.properties
src/jexer/TInputBox.java
src/jexer/TKeypress.java
src/jexer/TLabel.java
src/jexer/TList.java
src/jexer/TMessageBox.java
src/jexer/TMessageBox.properties
src/jexer/TPanel.java
src/jexer/TPasswordField.java
src/jexer/TProgressBar.java
src/jexer/TRadioButton.java
src/jexer/TRadioGroup.java
src/jexer/TScrollableWidget.java
src/jexer/TScrollableWindow.java
src/jexer/TSpinner.java
src/jexer/TSplitPane.java
src/jexer/TStatusBar.java
src/jexer/TTableWidget.java
src/jexer/TTableWindow.java
src/jexer/TTableWindow.properties
src/jexer/TTerminalWidget.java
src/jexer/TTerminalWidget.properties
src/jexer/TTerminalWindow.java
src/jexer/TTerminalWindow.properties
src/jexer/TText.java
src/jexer/TTimer.java
src/jexer/TVScroller.java
src/jexer/TWidget.java
src/jexer/TWindow.java
src/jexer/backend/Backend.java
src/jexer/backend/ECMA48Backend.java
src/jexer/backend/ECMA48Terminal.java
src/jexer/backend/GenericBackend.java
src/jexer/backend/GlyphMaker.java
src/jexer/backend/LogicalScreen.java
src/jexer/backend/MultiBackend.java
src/jexer/backend/MultiScreen.java
src/jexer/backend/Screen.java
src/jexer/backend/SessionInfo.java
src/jexer/backend/SwingBackend.java
src/jexer/backend/SwingComponent.java
src/jexer/backend/SwingSessionInfo.java
src/jexer/backend/SwingTerminal.java
src/jexer/backend/TSessionInfo.java
src/jexer/backend/TTYSessionInfo.java
src/jexer/backend/TWindowBackend.java
src/jexer/backend/TerminalReader.java
src/jexer/backend/package-info.java
src/jexer/bits/Cell.java
src/jexer/bits/CellAttributes.java
src/jexer/bits/Color.java
src/jexer/bits/ColorTheme.java
src/jexer/bits/GraphicsChars.java
src/jexer/bits/MnemonicString.java
src/jexer/bits/StringUtils.java
src/jexer/bits/package-info.java
src/jexer/demos/Demo1.java
src/jexer/demos/Demo2.java
src/jexer/demos/Demo2.properties
src/jexer/demos/Demo3.java
src/jexer/demos/Demo4.java
src/jexer/demos/Demo5.java
src/jexer/demos/Demo5.properties
src/jexer/demos/Demo6.java
src/jexer/demos/Demo6.properties
src/jexer/demos/Demo7.java
src/jexer/demos/Demo7.properties
src/jexer/demos/DemoApplication.java
src/jexer/demos/DemoApplication.properties
src/jexer/demos/DemoCheckBoxWindow.java
src/jexer/demos/DemoCheckBoxWindow.properties
src/jexer/demos/DemoEditorWindow.java
src/jexer/demos/DemoEditorWindow.properties
src/jexer/demos/DemoMainWindow.java
src/jexer/demos/DemoMainWindow.properties
src/jexer/demos/DemoMsgBoxWindow.java
src/jexer/demos/DemoMsgBoxWindow.properties
src/jexer/demos/DemoTableWindow.java
src/jexer/demos/DemoTableWindow.properties
src/jexer/demos/DemoTextFieldWindow.java
src/jexer/demos/DemoTextFieldWindow.properties
src/jexer/demos/DemoTextWindow.java
src/jexer/demos/DemoTextWindow.properties
src/jexer/demos/DemoTreeViewWindow.java
src/jexer/demos/DemoTreeViewWindow.properties
src/jexer/demos/DesktopDemo.java
src/jexer/demos/DesktopDemoApplication.java
src/jexer/demos/DesktopDemoApplication.properties
src/jexer/demos/package-info.java
src/jexer/event/TCommandEvent.java
src/jexer/event/TInputEvent.java
src/jexer/event/TKeypressEvent.java
src/jexer/event/TMenuEvent.java
src/jexer/event/TMouseEvent.java
src/jexer/event/TResizeEvent.java
src/jexer/event/package-info.java
src/jexer/io/ReadTimeoutException.java
src/jexer/io/TimeoutInputStream.java
src/jexer/io/package-info.java
src/jexer/layout/BoxLayoutManager.java
src/jexer/layout/LayoutManager.java
src/jexer/layout/StretchLayoutManager.java
src/jexer/layout/package-info.java
src/jexer/menu/TMenu.java
src/jexer/menu/TMenu.properties
src/jexer/menu/TMenuItem.java
src/jexer/menu/TMenuSeparator.java
src/jexer/menu/TSubMenu.java
src/jexer/menu/package-info.java
src/jexer/net/TelnetInputStream.java
src/jexer/net/TelnetOutputStream.java
src/jexer/net/TelnetServerSocket.java
src/jexer/net/TelnetSocket.java
src/jexer/net/package-info.java
src/jexer/package-info.java
src/jexer/resources/jexer_logo_128.png
src/jexer/resources/terminus-ttf-4.39/COPYING
src/jexer/resources/terminus-ttf-4.39/TerminusTTF-4.39.ttf
src/jexer/resources/terminus-ttf-4.39/TerminusTTF-Bold-4.39.ttf
src/jexer/resources/terminus-ttf-4.39/TerminusTTF-Italic-4.39.ttf
src/jexer/teditor/Document.java
src/jexer/teditor/Highlighter.java
src/jexer/teditor/Line.java
src/jexer/teditor/Word.java
src/jexer/teditor/package-info.java
src/jexer/tterminal/DECCharacterSets.java
src/jexer/tterminal/DisplayLine.java
src/jexer/tterminal/DisplayListener.java
src/jexer/tterminal/ECMA48.java
src/jexer/tterminal/Sixel.java
src/jexer/tterminal/package-info.java
src/jexer/ttree/TDirectoryTreeItem.java
src/jexer/ttree/TTreeItem.java
src/jexer/ttree/TTreeView.java
src/jexer/ttree/TTreeViewWidget.java
src/jexer/ttree/TTreeViewWindow.java
src/jexer/ttree/package-info.java

diff --combined src/jexer/.classpath
index 0000000000000000000000000000000000000000,9b07da8c94508232ec00dcf5c090d88b41eb952c..9b07da8c94508232ec00dcf5c090d88b41eb952c
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,6 +1,6 @@@
+ <?xml version="1.0" encoding="UTF-8"?>
+ <classpath>
+       <classpathentry kind="src" path="src"/>
+       <classpathentry exported="true" kind="con" path="org.eclipse.jdt.launching.JRE_CONTAINER"/>
+       <classpathentry kind="output" path="bin"/>
+ </classpath>
diff --combined src/jexer/.gitignore
index 0000000000000000000000000000000000000000,30d9f7c701397a07ff13ad91bb0cf1c59cdf3351..30d9f7c701397a07ff13ad91bb0cf1c59cdf3351
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,35 +1,35 @@@
+ *.class
+ bin/**
+ build/**
+ # Mobile Tools for Java (J2ME)
+ .mtj.tmp/
+ # Package Files #
+ *.jar
+ *.war
+ *.ear
+ # Generated docs
+ docs/**
+ # Maven artifacts
+ target/**
+ # virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
+ hs_err_pid*
+ # Editor backup files
+ *.java~
+ *.xml~
+ # Scratch space
+ misc/**
+ /.project~
+ pmd.bash
+ pmd-results.html
+ examples/*.sh
+ # Fonts for testing
+ fonts/**
diff --combined src/jexer/.project
index 0000000000000000000000000000000000000000,c0afd85c39f2b302d1a2dc7eaea205ce7adc4f87..c0afd85c39f2b302d1a2dc7eaea205ce7adc4f87
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,17 +1,17 @@@
+ <?xml version="1.0" encoding="UTF-8"?>
+ <projectDescription>
+       <name>jexer</name>
+       <comment></comment>
+       <projects>
+       </projects>
+       <buildSpec>
+               <buildCommand>
+                       <name>org.eclipse.jdt.core.javabuilder</name>
+                       <arguments>
+                       </arguments>
+               </buildCommand>
+       </buildSpec>
+       <natures>
+               <nature>org.eclipse.jdt.core.javanature</nature>
+       </natures>
+ </projectDescription>
index 0000000000000000000000000000000000000000,b844ca6f86027d2551c9cdba0755e51af76971fc..b844ca6f86027d2551c9cdba0755e51af76971fc
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,280 +1,280 @@@
+ /*
+  * Jexer - Java Text User Interface
+  *
+  * The MIT License (MIT)
+  *
+  * 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"),
+  * to deal in the Software without restriction, including without limitation
+  * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+  * and/or sell copies of the Software, and to permit persons to whom the
+  * Software is furnished to do so, subject to the following conditions:
+  *
+  * The above copyright notice and this permission notice shall be included in
+  * all copies or substantial portions of the Software.
+  *
+  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+  * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+  * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+  * DEALINGS IN THE SOFTWARE.
+  *
+  * @author Kevin Lamonte [kevin.lamonte@gmail.com]
+  * @version 1
+  */
+ package jexer;
+ /**
+  * Scrollable provides a public API for horizontal and vertical scrollbars.
+  * Note that not all Scrollables support both horizontal and vertical
+  * scrolling; for those that only support a subset, it is expected that the
+  * methods corresponding to the missing scrollbar quietly succeed without
+  * throwing any exceptions.
+  */
+ public interface Scrollable {
+     /**
+      * Get the horizontal scrollbar, or null if this Viewport does not
+      * support horizontal scrolling.
+      *
+      * @return the horizontal scrollbar
+      */
+     public THScroller getHorizontalScroller();
+     /**
+      * Get the vertical scrollbar, or null if this Viewport does not support
+      * vertical scrolling.
+      *
+      * @return the vertical scrollbar
+      */
+     public TVScroller getVerticalScroller();
+     /**
+      * Get the value that corresponds to being on the top edge of the
+      * vertical scroll bar.
+      *
+      * @return the scroll value
+      */
+     public int getTopValue();
+     /**
+      * Set the value that corresponds to being on the top edge of the
+      * vertical scroll bar.
+      *
+      * @param topValue the new scroll value
+      */
+     public void setTopValue(final int topValue);
+     /**
+      * Get the value that corresponds to being on the bottom edge of the
+      * vertical scroll bar.
+      *
+      * @return the scroll value
+      */
+     public int getBottomValue();
+     /**
+      * Set the value that corresponds to being on the bottom edge of the
+      * vertical scroll bar.
+      *
+      * @param bottomValue the new scroll value
+      */
+     public void setBottomValue(final int bottomValue);
+     /**
+      * Get current value of the vertical scroll.
+      *
+      * @return the scroll value
+      */
+     public int getVerticalValue();
+     /**
+      * Set current value of the vertical scroll.
+      *
+      * @param value the new scroll value
+      */
+     public void setVerticalValue(final int value);
+     /**
+      * Get the increment for clicking on an arrow on the vertical scrollbar.
+      *
+      * @return the increment value
+      */
+     public int getVerticalSmallChange();
+     /**
+      * Set the increment for clicking on an arrow on the vertical scrollbar.
+      *
+      * @param smallChange the new increment value
+      */
+     public void setVerticalSmallChange(final int smallChange);
+     /**
+      * Get the increment for clicking in the bar between the box and an
+      * arrow on the vertical scrollbar.
+      *
+      * @return the increment value
+      */
+     public int getVerticalBigChange();
+     /**
+      * Set the increment for clicking in the bar between the box and an
+      * arrow on the vertical scrollbar.
+      *
+      * @param bigChange the new increment value
+      */
+     public void setVerticalBigChange(final int bigChange);
+     /**
+      * Perform a small step change up.
+      */
+     public void verticalDecrement();
+     /**
+      * Perform a small step change down.
+      */
+     public void verticalIncrement();
+     /**
+      * Perform a big step change up.
+      */
+     public void bigVerticalDecrement();
+     /**
+      * Perform a big step change down.
+      */
+     public void bigVerticalIncrement();
+     /**
+      * Go to the top edge of the vertical scroller.
+      */
+     public void toTop();
+     /**
+      * Go to the bottom edge of the vertical scroller.
+      */
+     public void toBottom();
+     /**
+      * Get the value that corresponds to being on the left edge of the
+      * horizontal scroll bar.
+      *
+      * @return the scroll value
+      */
+     public int getLeftValue();
+     /**
+      * Set the value that corresponds to being on the left edge of the
+      * horizontal scroll bar.
+      *
+      * @param leftValue the new scroll value
+      */
+     public void setLeftValue(final int leftValue);
+     /**
+      * Get the value that corresponds to being on the right edge of the
+      * horizontal scroll bar.
+      *
+      * @return the scroll value
+      */
+     public int getRightValue();
+     /**
+      * Set the value that corresponds to being on the right edge of the
+      * horizontal scroll bar.
+      *
+      * @param rightValue the new scroll value
+      */
+     public void setRightValue(final int rightValue);
+     /**
+      * Get current value of the horizontal scroll.
+      *
+      * @return the scroll value
+      */
+     public int getHorizontalValue();
+     /**
+      * Set current value of the horizontal scroll.
+      *
+      * @param value the new scroll value
+      */
+     public void setHorizontalValue(final int value);
+     /**
+      * Get the increment for clicking on an arrow on the horizontal
+      * scrollbar.
+      *
+      * @return the increment value
+      */
+     public int getHorizontalSmallChange();
+     /**
+      * Set the increment for clicking on an arrow on the horizontal
+      * scrollbar.
+      *
+      * @param smallChange the new increment value
+      */
+     public void setHorizontalSmallChange(final int smallChange);
+     /**
+      * Get the increment for clicking in the bar between the box and an
+      * arrow on the horizontal scrollbar.
+      *
+      * @return the increment value
+      */
+     public int getHorizontalBigChange();
+     /**
+      * Set the increment for clicking in the bar between the box and an
+      * arrow on the horizontal scrollbar.
+      *
+      * @param bigChange the new increment value
+      */
+     public void setHorizontalBigChange(final int bigChange);
+     /**
+      * Perform a small step change left.
+      */
+     public void horizontalDecrement();
+     /**
+      * Perform a small step change right.
+      */
+     public void horizontalIncrement();
+     /**
+      * Perform a big step change left.
+      */
+     public void bigHorizontalDecrement();
+     /**
+      * Perform a big step change right.
+      */
+     public void bigHorizontalIncrement();
+     /**
+      * Go to the left edge of the horizontal scroller.
+      */
+     public void toLeft();
+     /**
+      * Go to the right edge of the horizontal scroller.
+      */
+     public void toRight();
+     /**
+      * Go to the top-left edge of the horizontal and vertical scrollers.
+      */
+     public void toHome();
+     /**
+      * Go to the bottom-right edge of the horizontal and vertical scrollers.
+      */
+     public void toEnd();
+ }
diff --combined src/jexer/TAction.java
index 0000000000000000000000000000000000000000,5343143cc223ebfd3fa8702c7ddb3959d1c7b362..5343143cc223ebfd3fa8702c7ddb3959d1c7b362
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,75 +1,75 @@@
+ /*
+  * Jexer - Java Text User Interface
+  *
+  * The MIT License (MIT)
+  *
+  * 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"),
+  * to deal in the Software without restriction, including without limitation
+  * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+  * and/or sell copies of the Software, and to permit persons to whom the
+  * Software is furnished to do so, subject to the following conditions:
+  *
+  * The above copyright notice and this permission notice shall be included in
+  * all copies or substantial portions of the Software.
+  *
+  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+  * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+  * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+  * DEALINGS IN THE SOFTWARE.
+  *
+  * @author Kevin Lamonte [kevin.lamonte@gmail.com]
+  * @version 1
+  */
+ package jexer;
+ /**
+  * A TAction represents a simple action to perform in response to the user.
+  *
+  * @see TButton
+  */
+ public abstract class TAction {
+     /**
+      * The widget that called this action's DO() method.  Note that this
+      * field could be null, for example if executed as a timer action.
+      */
+     public TWidget source;
+     /**
+      * An optional bit of data associated with this action.
+      */
+     public Object data;
+     /**
+      * Call DO() with source widget set.
+      *
+      * @param source the source widget
+      */
+     public final void DO(final TWidget source) {
+         this.source = source;
+         DO();
+     }
+     /**
+      * Call DO() with source widget and data set.
+      *
+      * @param source the source widget
+      * @param data the data
+      */
+     public final void DO(final TWidget source, final Object data) {
+         this.source = source;
+         this.data = data;
+         DO();
+     }
+     /**
+      * Various classes will call DO() when they are clicked/selected.
+      */
+     public abstract void DO();
+ }
index 0000000000000000000000000000000000000000,9d27c10f5420052103cee046baae697ca8c2bd6e..9d27c10f5420052103cee046baae697ca8c2bd6e
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,3818 +1,3818 @@@
+ /*
+  * Jexer - Java Text User Interface
+  *
+  * The MIT License (MIT)
+  *
+  * 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"),
+  * to deal in the Software without restriction, including without limitation
+  * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+  * and/or sell copies of the Software, and to permit persons to whom the
+  * Software is furnished to do so, subject to the following conditions:
+  *
+  * The above copyright notice and this permission notice shall be included in
+  * all copies or substantial portions of the Software.
+  *
+  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+  * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+  * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+  * DEALINGS IN THE SOFTWARE.
+  *
+  * @author Kevin Lamonte [kevin.lamonte@gmail.com]
+  * @version 1
+  */
+ package jexer;
+ import java.io.File;
+ import java.io.InputStream;
+ import java.io.IOException;
+ import java.io.OutputStream;
+ import java.io.PrintWriter;
+ import java.io.Reader;
+ import java.io.UnsupportedEncodingException;
+ import java.text.MessageFormat;
+ import java.util.ArrayList;
+ import java.util.Collections;
+ import java.util.Date;
+ import java.util.HashMap;
+ import java.util.LinkedList;
+ import java.util.List;
+ import java.util.Map;
+ import java.util.ResourceBundle;
+ import jexer.bits.Cell;
+ import jexer.bits.CellAttributes;
+ import jexer.bits.ColorTheme;
+ import jexer.bits.StringUtils;
+ import jexer.event.TCommandEvent;
+ import jexer.event.TInputEvent;
+ import jexer.event.TKeypressEvent;
+ import jexer.event.TMenuEvent;
+ import jexer.event.TMouseEvent;
+ import jexer.event.TResizeEvent;
+ import jexer.backend.Backend;
+ import jexer.backend.MultiBackend;
+ import jexer.backend.Screen;
+ import jexer.backend.SwingBackend;
+ import jexer.backend.ECMA48Backend;
+ import jexer.backend.TWindowBackend;
+ import jexer.menu.TMenu;
+ import jexer.menu.TMenuItem;
+ import jexer.menu.TSubMenu;
+ import static jexer.TCommand.*;
+ import static jexer.TKeypress.*;
+ /**
+  * TApplication is the main driver class for a full Text User Interface
+  * application.  It manages windows, provides a menu bar and status bar, and
+  * processes events received from the user.
+  */
+ public class TApplication implements Runnable {
+     /**
+      * Translated strings.
+      */
+     private static final ResourceBundle i18n = ResourceBundle.getBundle(TApplication.class.getName());
+     // ------------------------------------------------------------------------
+     // Constants --------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * If true, emit thread stuff to System.err.
+      */
+     private static final boolean debugThreads = false;
+     /**
+      * If true, emit events being processed to System.err.
+      */
+     private static final boolean debugEvents = false;
+     /**
+      * If true, do "smart placement" on new windows that are not specified to
+      * be centered.
+      */
+     private static final boolean smartWindowPlacement = true;
+     /**
+      * Two backend types are available.
+      */
+     public static enum BackendType {
+         /**
+          * A Swing JFrame.
+          */
+         SWING,
+         /**
+          * An ECMA48 / ANSI X3.64 / XTERM style terminal.
+          */
+         ECMA48,
+         /**
+          * Synonym for ECMA48.
+          */
+         XTERM
+     }
+     // ------------------------------------------------------------------------
+     // Variables --------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * The primary event handler thread.
+      */
+     private volatile WidgetEventHandler primaryEventHandler;
+     /**
+      * The secondary event handler thread.
+      */
+     private volatile WidgetEventHandler secondaryEventHandler;
+     /**
+      * The screen handler thread.
+      */
+     private volatile ScreenHandler screenHandler;
+     /**
+      * The widget receiving events from the secondary event handler thread.
+      */
+     private volatile TWidget secondaryEventReceiver;
+     /**
+      * Access to the physical screen, keyboard, and mouse.
+      */
+     private Backend backend;
+     /**
+      * Actual mouse coordinate X.
+      */
+     private int mouseX;
+     /**
+      * Actual mouse coordinate Y.
+      */
+     private int mouseY;
+     /**
+      * Old version of mouse coordinate X.
+      */
+     private int oldMouseX;
+     /**
+      * Old version mouse coordinate Y.
+      */
+     private int oldMouseY;
+     /**
+      * Old drawn version of mouse coordinate X.
+      */
+     private int oldDrawnMouseX;
+     /**
+      * Old drawn version mouse coordinate Y.
+      */
+     private int oldDrawnMouseY;
+     /**
+      * Old drawn version mouse cell.
+      */
+     private Cell oldDrawnMouseCell = new Cell();
+     /**
+      * The last mouse up click time, used to determine if this is a mouse
+      * double-click.
+      */
+     private long lastMouseUpTime;
+     /**
+      * The amount of millis between mouse up events to assume a double-click.
+      */
+     private long doubleClickTime = 250;
+     /**
+      * Event queue that is filled by run().
+      */
+     private List<TInputEvent> fillEventQueue;
+     /**
+      * Event queue that will be drained by either primary or secondary
+      * Thread.
+      */
+     private List<TInputEvent> drainEventQueue;
+     /**
+      * Top-level menus in this application.
+      */
+     private List<TMenu> menus;
+     /**
+      * Stack of activated sub-menus in this application.
+      */
+     private List<TMenu> subMenus;
+     /**
+      * The currently active menu.
+      */
+     private TMenu activeMenu = null;
+     /**
+      * Active keyboard accelerators.
+      */
+     private Map<TKeypress, TMenuItem> accelerators;
+     /**
+      * All menu items.
+      */
+     private List<TMenuItem> menuItems;
+     /**
+      * Windows and widgets pull colors from this ColorTheme.
+      */
+     private ColorTheme theme;
+     /**
+      * The top-level windows (but not menus).
+      */
+     private List<TWindow> windows;
+     /**
+      * The currently acive window.
+      */
+     private TWindow activeWindow = null;
+     /**
+      * Timers that are being ticked.
+      */
+     private List<TTimer> timers;
+     /**
+      * When true, the application has been started.
+      */
+     private volatile boolean started = false;
+     /**
+      * When true, exit the application.
+      */
+     private volatile boolean quit = false;
+     /**
+      * When true, repaint the entire screen.
+      */
+     private volatile boolean repaint = true;
+     /**
+      * Y coordinate of the top edge of the desktop.  For now this is a
+      * constant.  Someday it would be nice to have a multi-line menu or
+      * toolbars.
+      */
+     private int desktopTop = 1;
+     /**
+      * Y coordinate of the bottom edge of the desktop.
+      */
+     private int desktopBottom;
+     /**
+      * An optional TDesktop background window that is drawn underneath
+      * everything else.
+      */
+     private TDesktop desktop;
+     /**
+      * If true, focus follows mouse: windows automatically raised if the
+      * mouse passes over them.
+      */
+     private boolean focusFollowsMouse = false;
+     /**
+      * If true, display a text-based mouse cursor.
+      */
+     private boolean textMouse = true;
+     /**
+      * If true, hide the mouse after typing a keystroke.
+      */
+     private boolean hideMouseWhenTyping = false;
+     /**
+      * If true, the mouse should not be displayed because a keystroke was
+      * typed.
+      */
+     private boolean typingHidMouse = false;
+     /**
+      * If true, hide the status bar.
+      */
+     private boolean hideStatusBar = false;
+     /**
+      * If true, hide the menu bar.
+      */
+     private boolean hideMenuBar = false;
+     /**
+      * The list of commands to run before the next I/O check.
+      */
+     private List<Runnable> invokeLaters = new LinkedList<Runnable>();
+     /**
+      * The last time the screen was resized.
+      */
+     private long screenResizeTime = 0;
+     /**
+      * WidgetEventHandler is the main event consumer loop.  There are at most
+      * two such threads in existence: the primary for normal case and a
+      * secondary that is used for TMessageBox, TInputBox, and similar.
+      */
+     private class WidgetEventHandler implements Runnable {
+         /**
+          * The main application.
+          */
+         private TApplication application;
+         /**
+          * Whether or not this WidgetEventHandler is the primary or secondary
+          * thread.
+          */
+         private boolean primary = true;
+         /**
+          * Public constructor.
+          *
+          * @param application the main application
+          * @param primary if true, this is the primary event handler thread
+          */
+         public WidgetEventHandler(final TApplication application,
+             final boolean primary) {
+             this.application = application;
+             this.primary = primary;
+         }
+         /**
+          * The consumer loop.
+          */
+         public void run() {
+             // Wrap everything in a try, so that if we go belly up we can let
+             // the user have their terminal back.
+             try {
+                 runImpl();
+             } catch (Throwable t) {
+                 this.application.restoreConsole();
+                 t.printStackTrace();
+                 this.application.exit();
+             }
+         }
+         /**
+          * The consumer loop.
+          */
+         private void runImpl() {
+             boolean first = true;
+             // Loop forever
+             while (!application.quit) {
+                 // Wait until application notifies me
+                 while (!application.quit) {
+                     try {
+                         synchronized (application.drainEventQueue) {
+                             if (application.drainEventQueue.size() > 0) {
+                                 break;
+                             }
+                         }
+                         long timeout = 0;
+                         if (first) {
+                             first = false;
+                         } else {
+                             timeout = application.getSleepTime(1000);
+                         }
+                         if (timeout == 0) {
+                             // A timer needs to fire, break out.
+                             break;
+                         }
+                         if (debugThreads) {
+                             System.err.printf("%d %s %s %s sleep %d millis\n",
+                                 System.currentTimeMillis(), this,
+                                 primary ? "primary" : "secondary",
+                                 Thread.currentThread(), timeout);
+                         }
+                         synchronized (this) {
+                             this.wait(timeout);
+                         }
+                         if (debugThreads) {
+                             System.err.printf("%d %s %s %s AWAKE\n",
+                                 System.currentTimeMillis(), this,
+                                 primary ? "primary" : "secondary",
+                                 Thread.currentThread());
+                         }
+                         if ((!primary)
+                             && (application.secondaryEventReceiver == null)
+                         ) {
+                             // Secondary thread, emergency exit.  If we got
+                             // here then something went wrong with the
+                             // handoff between yield() and closeWindow().
+                             synchronized (application.primaryEventHandler) {
+                                 application.primaryEventHandler.notify();
+                             }
+                             application.secondaryEventHandler = null;
+                             throw new RuntimeException("secondary exited " +
+                                 "at wrong time");
+                         }
+                         break;
+                     } catch (InterruptedException e) {
+                         // SQUASH
+                     }
+                 } // while (!application.quit)
+                 // Pull all events off the queue
+                 for (;;) {
+                     TInputEvent event = null;
+                     synchronized (application.drainEventQueue) {
+                         if (application.drainEventQueue.size() == 0) {
+                             break;
+                         }
+                         event = application.drainEventQueue.remove(0);
+                     }
+                     // We will have an event to process, so repaint the
+                     // screen at the end.
+                     application.repaint = true;
+                     if (primary) {
+                         primaryHandleEvent(event);
+                     } else {
+                         secondaryHandleEvent(event);
+                     }
+                     if ((!primary)
+                         && (application.secondaryEventReceiver == null)
+                     ) {
+                         // Secondary thread, time to exit.
+                         // Eliminate my reference so that wakeEventHandler()
+                         // resumes working on the primary.
+                         application.secondaryEventHandler = null;
+                         // We are ready to exit, wake up the primary thread.
+                         // Remember that it is currently sleeping inside its
+                         // primaryHandleEvent().
+                         synchronized (application.primaryEventHandler) {
+                             application.primaryEventHandler.notify();
+                         }
+                         // All done!
+                         return;
+                     }
+                 } // for (;;)
+                 // Fire timers, update screen.
+                 if (!quit) {
+                     application.finishEventProcessing();
+                 }
+             } // while (true) (main runnable loop)
+         }
+     }
+     /**
+      * ScreenHandler pushes screen updates to the physical device.
+      */
+     private class ScreenHandler implements Runnable {
+         /**
+          * The main application.
+          */
+         private TApplication application;
+         /**
+          * The dirty flag.
+          */
+         private boolean dirty = false;
+         /**
+          * Public constructor.
+          *
+          * @param application the main application
+          */
+         public ScreenHandler(final TApplication application) {
+             this.application = application;
+         }
+         /**
+          * The screen update loop.
+          */
+         public void run() {
+             // Wrap everything in a try, so that if we go belly up we can let
+             // the user have their terminal back.
+             try {
+                 runImpl();
+             } catch (Throwable t) {
+                 this.application.restoreConsole();
+                 t.printStackTrace();
+                 this.application.exit();
+             }
+         }
+         /**
+          * The update loop.
+          */
+         private void runImpl() {
+             // Loop forever
+             while (!application.quit) {
+                 // Wait until application notifies me
+                 while (!application.quit) {
+                     try {
+                         synchronized (this) {
+                             if (dirty) {
+                                 dirty = false;
+                                 break;
+                             }
+                             // Always check within 50 milliseconds.
+                             this.wait(50);
+                         }
+                     } catch (InterruptedException e) {
+                         // SQUASH
+                     }
+                 } // while (!application.quit)
+                  // Flush the screen contents
+                 if (debugThreads) {
+                     System.err.printf("%d %s backend.flushScreen()\n",
+                         System.currentTimeMillis(), Thread.currentThread());
+                 }
+                 synchronized (getScreen()) {
+                     backend.flushScreen();
+                 }
+             } // while (true) (main runnable loop)
+             // Shutdown the user I/O thread(s)
+             backend.shutdown();
+         }
+         /**
+          * Set the dirty flag.
+          */
+         public void setDirty() {
+             synchronized (this) {
+                 dirty = true;
+             }
+         }
+     }
+     // ------------------------------------------------------------------------
+     // Constructors -----------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Public constructor.
+      *
+      * @param backendType BackendType.XTERM, BackendType.ECMA48 or
+      * BackendType.SWING
+      * @param windowWidth the number of text columns to start with
+      * @param windowHeight the number of text rows to start with
+      * @param fontSize the size in points
+      * @throws UnsupportedEncodingException if an exception is thrown when
+      * creating the InputStreamReader
+      */
+     public TApplication(final BackendType backendType, final int windowWidth,
+         final int windowHeight, final int fontSize)
+         throws UnsupportedEncodingException {
+         switch (backendType) {
+         case SWING:
+             backend = new SwingBackend(this, windowWidth, windowHeight,
+                 fontSize);
+             break;
+         case XTERM:
+             // Fall through...
+         case ECMA48:
+             backend = new ECMA48Backend(this, null, null, windowWidth,
+                 windowHeight, fontSize);
+             break;
+         default:
+             throw new IllegalArgumentException("Invalid backend type: "
+                 + backendType);
+         }
+         TApplicationImpl();
+     }
+     /**
+      * Public constructor.
+      *
+      * @param backendType BackendType.XTERM, BackendType.ECMA48 or
+      * BackendType.SWING
+      * @throws UnsupportedEncodingException if an exception is thrown when
+      * creating the InputStreamReader
+      */
+     public TApplication(final BackendType backendType)
+         throws UnsupportedEncodingException {
+         switch (backendType) {
+         case SWING:
+             // The default SwingBackend is 80x25, 20 pt font.  If you want to
+             // change that, you can pass the extra arguments to the
+             // SwingBackend constructor here.  For example, if you wanted
+             // 90x30, 16 pt font:
+             //
+             // backend = new SwingBackend(this, 90, 30, 16);
+             backend = new SwingBackend(this);
+             break;
+         case XTERM:
+             // Fall through...
+         case ECMA48:
+             backend = new ECMA48Backend(this, null, null);
+             break;
+         default:
+             throw new IllegalArgumentException("Invalid backend type: "
+                 + backendType);
+         }
+         TApplicationImpl();
+     }
+     /**
+      * Public constructor.  The backend type will be BackendType.ECMA48.
+      *
+      * @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.
+      * @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 TApplication(final InputStream input,
+         final OutputStream output) throws UnsupportedEncodingException {
+         backend = new ECMA48Backend(this, input, output);
+         TApplicationImpl();
+     }
+     /**
+      * Public constructor.  The backend type will be BackendType.ECMA48.
+      *
+      * @param input the InputStream underlying 'reader'.  Its available()
+      * method is used to determine if reader.read() will block or not.
+      * @param reader a Reader connected to the remote user.
+      * @param writer a PrintWriter connected to the remote user.
+      * @param setRawMode if true, set System.in into raw mode with stty.
+      * This should in general not be used.  It is here solely for Demo3,
+      * which uses System.in.
+      * @throws IllegalArgumentException if input, reader, or writer are null.
+      */
+     public TApplication(final InputStream input, final Reader reader,
+         final PrintWriter writer, final boolean setRawMode) {
+         backend = new ECMA48Backend(this, input, reader, writer, setRawMode);
+         TApplicationImpl();
+     }
+     /**
+      * Public constructor.  The backend type will be BackendType.ECMA48.
+      *
+      * @param input the InputStream underlying 'reader'.  Its available()
+      * method is used to determine if reader.read() will block or not.
+      * @param reader a Reader connected to the remote user.
+      * @param writer a PrintWriter connected to the remote user.
+      * @throws IllegalArgumentException if input, reader, or writer are null.
+      */
+     public TApplication(final InputStream input, final Reader reader,
+         final PrintWriter writer) {
+         this(input, reader, writer, false);
+     }
+     /**
+      * Public constructor.  This hook enables use with new non-Jexer
+      * backends.
+      *
+      * @param backend a Backend that is already ready to go.
+      */
+     public TApplication(final Backend backend) {
+         this.backend = backend;
+         backend.setListener(this);
+         TApplicationImpl();
+     }
+     /**
+      * Finish construction once the backend is set.
+      */
+     private void TApplicationImpl() {
+         // Text block mouse option
+         if (System.getProperty("jexer.textMouse", "true").equals("false")) {
+             textMouse = false;
+         }
+         // Hide mouse when typing option
+         if (System.getProperty("jexer.hideMouseWhenTyping",
+                 "false").equals("true")) {
+             hideMouseWhenTyping = true;
+         }
+         // Hide status bar option
+         if (System.getProperty("jexer.hideStatusBar",
+                 "false").equals("true")) {
+             hideStatusBar = true;
+         }
+         // Hide menu bar option
+         if (System.getProperty("jexer.hideMenuBar", "false").equals("true")) {
+             hideMenuBar = true;
+         }
+         theme           = new ColorTheme();
+         desktopTop      = (hideMenuBar ? 0 : 1);
+         desktopBottom   = getScreen().getHeight() - 1 + (hideStatusBar ? 1 : 0);
+         fillEventQueue  = new LinkedList<TInputEvent>();
+         drainEventQueue = new LinkedList<TInputEvent>();
+         windows         = new LinkedList<TWindow>();
+         menus           = new ArrayList<TMenu>();
+         subMenus        = new ArrayList<TMenu>();
+         timers          = new LinkedList<TTimer>();
+         accelerators    = new HashMap<TKeypress, TMenuItem>();
+         menuItems       = new LinkedList<TMenuItem>();
+         desktop         = new TDesktop(this);
+         // Special case: the Swing backend needs to have a timer to drive its
+         // blink state.
+         if ((backend instanceof SwingBackend)
+             || (backend instanceof MultiBackend)
+         ) {
+             // Default to 500 millis, unless a SwingBackend has its own
+             // value.
+             long millis = 500;
+             if (backend instanceof SwingBackend) {
+                 millis = ((SwingBackend) backend).getBlinkMillis();
+             }
+             if (millis > 0) {
+                 addTimer(millis, true,
+                     new TAction() {
+                         public void DO() {
+                             TApplication.this.doRepaint();
+                         }
+                     }
+                 );
+             }
+         }
+     }
+     // ------------------------------------------------------------------------
+     // Runnable ---------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Run this application until it exits.
+      */
+     public void run() {
+         // System.err.println("*** TApplication.run() begins ***");
+         // Start the screen updater thread
+         screenHandler = new ScreenHandler(this);
+         (new Thread(screenHandler)).start();
+         // Start the main consumer thread
+         primaryEventHandler = new WidgetEventHandler(this, true);
+         (new Thread(primaryEventHandler)).start();
+         started = true;
+         while (!quit) {
+             synchronized (this) {
+                 boolean doWait = false;
+                 if (!backend.hasEvents()) {
+                     synchronized (fillEventQueue) {
+                         if (fillEventQueue.size() == 0) {
+                             doWait = true;
+                         }
+                     }
+                 }
+                 if (doWait) {
+                     // No I/O to dispatch, so wait until the backend
+                     // provides new I/O.
+                     try {
+                         if (debugThreads) {
+                             System.err.println(System.currentTimeMillis() +
+                                 " " + Thread.currentThread() + " MAIN sleep");
+                         }
+                         this.wait();
+                         if (debugThreads) {
+                             System.err.println(System.currentTimeMillis() +
+                                 " " + Thread.currentThread() + " MAIN AWAKE");
+                         }
+                     } catch (InterruptedException e) {
+                         // I'm awake and don't care why, let's see what's
+                         // going on out there.
+                     }
+                 }
+             } // synchronized (this)
+             synchronized (fillEventQueue) {
+                 // Pull any pending I/O events
+                 backend.getEvents(fillEventQueue);
+                 // Dispatch each event to the appropriate handler, one at a
+                 // time.
+                 for (;;) {
+                     TInputEvent event = null;
+                     if (fillEventQueue.size() == 0) {
+                         break;
+                     }
+                     event = fillEventQueue.remove(0);
+                     metaHandleEvent(event);
+                 }
+             }
+             // Wake a consumer thread if we have any pending events.
+             if (drainEventQueue.size() > 0) {
+                 wakeEventHandler();
+             }
+         } // while (!quit)
+         // Shutdown the event consumer threads
+         if (secondaryEventHandler != null) {
+             synchronized (secondaryEventHandler) {
+                 secondaryEventHandler.notify();
+             }
+         }
+         if (primaryEventHandler != null) {
+             synchronized (primaryEventHandler) {
+                 primaryEventHandler.notify();
+             }
+         }
+         // Close all the windows.  This gives them an opportunity to release
+         // resources.
+         closeAllWindows();
+         // Close the desktop.
+         if (desktop != null) {
+             setDesktop(null);
+         }
+         // Give the overarching application an opportunity to release
+         // resources.
+         onExit();
+         // System.err.println("*** TApplication.run() exits ***");
+     }
+     // ------------------------------------------------------------------------
+     // Event handlers ---------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Method that TApplication subclasses can override to handle menu or
+      * posted command events.
+      *
+      * @param command command event
+      * @return if true, this event was consumed
+      */
+     protected boolean onCommand(final TCommandEvent command) {
+         // Default: handle cmExit
+         if (command.equals(cmExit)) {
+             if (messageBox(i18n.getString("exitDialogTitle"),
+                     i18n.getString("exitDialogText"),
+                     TMessageBox.Type.YESNO).isYes()) {
+                 exit();
+             }
+             return true;
+         }
+         if (command.equals(cmShell)) {
+             openTerminal(0, 0, TWindow.RESIZABLE);
+             return true;
+         }
+         if (command.equals(cmTile)) {
+             tileWindows();
+             return true;
+         }
+         if (command.equals(cmCascade)) {
+             cascadeWindows();
+             return true;
+         }
+         if (command.equals(cmCloseAll)) {
+             closeAllWindows();
+             return true;
+         }
+         if (command.equals(cmMenu) && (hideMenuBar == false)) {
+             if (!modalWindowActive() && (activeMenu == null)) {
+                 if (menus.size() > 0) {
+                     menus.get(0).setActive(true);
+                     activeMenu = menus.get(0);
+                     return true;
+                 }
+             }
+         }
+         return false;
+     }
+     /**
+      * Method that TApplication subclasses can override to handle menu
+      * events.
+      *
+      * @param menu menu event
+      * @return if true, this event was consumed
+      */
+     protected boolean onMenu(final TMenuEvent menu) {
+         // Default: handle MID_EXIT
+         if (menu.getId() == TMenu.MID_EXIT) {
+             if (messageBox(i18n.getString("exitDialogTitle"),
+                     i18n.getString("exitDialogText"),
+                     TMessageBox.Type.YESNO).isYes()) {
+                 exit();
+             }
+             return true;
+         }
+         if (menu.getId() == TMenu.MID_SHELL) {
+             openTerminal(0, 0, TWindow.RESIZABLE);
+             return true;
+         }
+         if (menu.getId() == TMenu.MID_TILE) {
+             tileWindows();
+             return true;
+         }
+         if (menu.getId() == TMenu.MID_CASCADE) {
+             cascadeWindows();
+             return true;
+         }
+         if (menu.getId() == TMenu.MID_CLOSE_ALL) {
+             closeAllWindows();
+             return true;
+         }
+         if (menu.getId() == TMenu.MID_ABOUT) {
+             showAboutDialog();
+             return true;
+         }
+         if (menu.getId() == TMenu.MID_REPAINT) {
+             getScreen().clearPhysical();
+             doRepaint();
+             return true;
+         }
+         if (menu.getId() == TMenu.MID_VIEW_IMAGE) {
+             openImage();
+             return true;
+         }
+         if (menu.getId() == TMenu.MID_SCREEN_OPTIONS) {
+             new TFontChooserWindow(this);
+             return true;
+         }
+         return false;
+     }
+     /**
+      * Method that TApplication subclasses can override to handle keystrokes.
+      *
+      * @param keypress keystroke event
+      * @return if true, this event was consumed
+      */
+     protected boolean onKeypress(final TKeypressEvent keypress) {
+         // Default: only menu shortcuts
+         // Process Alt-F, Alt-E, etc. menu shortcut keys
+         if (!keypress.getKey().isFnKey()
+             && keypress.getKey().isAlt()
+             && !keypress.getKey().isCtrl()
+             && (activeMenu == null)
+             && !modalWindowActive()
+             && (hideMenuBar == false)
+         ) {
+             assert (subMenus.size() == 0);
+             for (TMenu menu: menus) {
+                 if (Character.toLowerCase(menu.getMnemonic().getShortcut())
+                     == Character.toLowerCase(keypress.getKey().getChar())
+                 ) {
+                     activeMenu = menu;
+                     menu.setActive(true);
+                     return true;
+                 }
+             }
+         }
+         return false;
+     }
+     /**
+      * Process background events, and update the screen.
+      */
+     private void finishEventProcessing() {
+         if (debugThreads) {
+             System.err.printf(System.currentTimeMillis() + " " +
+                 Thread.currentThread() + " finishEventProcessing()\n");
+         }
+         // Process timers and call doIdle()'s
+         doIdle();
+         // Update the screen
+         synchronized (getScreen()) {
+             drawAll();
+         }
+         // Wake up the screen repainter
+         wakeScreenHandler();
+         if (debugThreads) {
+             System.err.printf(System.currentTimeMillis() + " " +
+                 Thread.currentThread() + " finishEventProcessing() END\n");
+         }
+     }
+     /**
+      * Peek at certain application-level events, add to eventQueue, and wake
+      * up the consuming Thread.
+      *
+      * @param event the input event to consume
+      */
+     private void metaHandleEvent(final TInputEvent event) {
+         if (debugEvents) {
+             System.err.printf(String.format("metaHandleEvents event: %s\n",
+                     event)); System.err.flush();
+         }
+         if (quit) {
+             // Do no more processing if the application is already trying
+             // to exit.
+             return;
+         }
+         // Special application-wide events -------------------------------
+         // Abort everything
+         if (event instanceof TCommandEvent) {
+             TCommandEvent command = (TCommandEvent) event;
+             if (command.equals(cmAbort)) {
+                 exit();
+                 return;
+             }
+         }
+         synchronized (drainEventQueue) {
+             // Screen resize
+             if (event instanceof TResizeEvent) {
+                 TResizeEvent resize = (TResizeEvent) event;
+                 synchronized (getScreen()) {
+                     if ((System.currentTimeMillis() - screenResizeTime >= 15)
+                         || (resize.getWidth() < getScreen().getWidth())
+                         || (resize.getHeight() < getScreen().getHeight())
+                     ) {
+                         getScreen().setDimensions(resize.getWidth(),
+                             resize.getHeight());
+                         screenResizeTime = System.currentTimeMillis();
+                     }
+                     desktopBottom = getScreen().getHeight() - 1;
+                     if (hideStatusBar) {
+                         desktopBottom++;
+                     }
+                     mouseX = 0;
+                     mouseY = 0;
+                     oldMouseX = 0;
+                     oldMouseY = 0;
+                 }
+                 if (desktop != null) {
+                     desktop.setDimensions(0, desktopTop, resize.getWidth(),
+                         (desktopBottom - desktopTop));
+                     desktop.onResize(resize);
+                 }
+                 // Change menu edges if needed.
+                 recomputeMenuX();
+                 // We are dirty, redraw the screen.
+                 doRepaint();
+                 /*
+                 System.err.println("New screen: " + resize.getWidth() +
+                     " x " + resize.getHeight());
+                 */
+                 return;
+             }
+             // Put into the main queue
+             drainEventQueue.add(event);
+         }
+     }
+     /**
+      * Dispatch one event to the appropriate widget or application-level
+      * event handler.  This is the primary event handler, it has the normal
+      * application-wide event handling.
+      *
+      * @param event the input event to consume
+      * @see #secondaryHandleEvent(TInputEvent event)
+      */
+     private void primaryHandleEvent(final TInputEvent event) {
+         if (debugEvents) {
+             System.err.printf("%s primaryHandleEvent: %s\n",
+                 Thread.currentThread(), event);
+         }
+         TMouseEvent doubleClick = null;
+         // Special application-wide events -----------------------------------
+         if (event instanceof TKeypressEvent) {
+             if (hideMouseWhenTyping) {
+                 typingHidMouse = true;
+             }
+         }
+         // Peek at the mouse position
+         if (event instanceof TMouseEvent) {
+             typingHidMouse = false;
+             TMouseEvent mouse = (TMouseEvent) event;
+             if ((mouseX != mouse.getX()) || (mouseY != mouse.getY())) {
+                 oldMouseX = mouseX;
+                 oldMouseY = mouseY;
+                 mouseX = mouse.getX();
+                 mouseY = mouse.getY();
+             } else {
+                 if ((mouse.getType() == TMouseEvent.Type.MOUSE_DOWN)
+                     && (!mouse.isMouseWheelUp())
+                     && (!mouse.isMouseWheelDown())
+                 ) {
+                     if ((mouse.getTime().getTime() - lastMouseUpTime) <
+                         doubleClickTime) {
+                         // This is a double-click.
+                         doubleClick = new TMouseEvent(TMouseEvent.Type.
+                             MOUSE_DOUBLE_CLICK,
+                             mouse.getX(), mouse.getY(),
+                             mouse.getAbsoluteX(), mouse.getAbsoluteY(),
+                             mouse.isMouse1(), mouse.isMouse2(),
+                             mouse.isMouse3(),
+                             mouse.isMouseWheelUp(), mouse.isMouseWheelDown());
+                     } else {
+                         // The first click of a potential double-click.
+                         lastMouseUpTime = mouse.getTime().getTime();
+                     }
+                 }
+             }
+             // See if we need to switch focus to another window or the menu
+             checkSwitchFocus((TMouseEvent) event);
+         }
+         // Handle menu events
+         if ((activeMenu != null) && !(event instanceof TCommandEvent)) {
+             TMenu menu = activeMenu;
+             if (event instanceof TMouseEvent) {
+                 TMouseEvent mouse = (TMouseEvent) event;
+                 while (subMenus.size() > 0) {
+                     TMenu subMenu = subMenus.get(subMenus.size() - 1);
+                     if (subMenu.mouseWouldHit(mouse)) {
+                         break;
+                     }
+                     if ((mouse.getType() == TMouseEvent.Type.MOUSE_MOTION)
+                         && (!mouse.isMouse1())
+                         && (!mouse.isMouse2())
+                         && (!mouse.isMouse3())
+                         && (!mouse.isMouseWheelUp())
+                         && (!mouse.isMouseWheelDown())
+                     ) {
+                         break;
+                     }
+                     // We navigated away from a sub-menu, so close it
+                     closeSubMenu();
+                 }
+                 // Convert the mouse relative x/y to menu coordinates
+                 assert (mouse.getX() == mouse.getAbsoluteX());
+                 assert (mouse.getY() == mouse.getAbsoluteY());
+                 if (subMenus.size() > 0) {
+                     menu = subMenus.get(subMenus.size() - 1);
+                 }
+                 mouse.setX(mouse.getX() - menu.getX());
+                 mouse.setY(mouse.getY() - menu.getY());
+             }
+             menu.handleEvent(event);
+             return;
+         }
+         if (event instanceof TKeypressEvent) {
+             TKeypressEvent keypress = (TKeypressEvent) event;
+             // See if this key matches an accelerator, and is not being
+             // shortcutted by the active window, and if so dispatch the menu
+             // event.
+             boolean windowWillShortcut = false;
+             if (activeWindow != null) {
+                 assert (activeWindow.isShown());
+                 if (activeWindow.isShortcutKeypress(keypress.getKey())) {
+                     // We do not process this key, it will be passed to the
+                     // window instead.
+                     windowWillShortcut = true;
+                 }
+             }
+             if (!windowWillShortcut && !modalWindowActive()) {
+                 TKeypress keypressLowercase = keypress.getKey().toLowerCase();
+                 TMenuItem item = null;
+                 synchronized (accelerators) {
+                     item = accelerators.get(keypressLowercase);
+                 }
+                 if (item != null) {
+                     if (item.isEnabled()) {
+                         // Let the menu item dispatch
+                         item.dispatch();
+                         return;
+                     }
+                 }
+                 // Handle the keypress
+                 if (onKeypress(keypress)) {
+                     return;
+                 }
+             }
+         }
+         if (event instanceof TCommandEvent) {
+             if (onCommand((TCommandEvent) event)) {
+                 return;
+             }
+         }
+         if (event instanceof TMenuEvent) {
+             if (onMenu((TMenuEvent) event)) {
+                 return;
+             }
+         }
+         // Dispatch events to the active window -------------------------------
+         boolean dispatchToDesktop = true;
+         TWindow window = activeWindow;
+         if (window != null) {
+             assert (window.isActive());
+             assert (window.isShown());
+             if (event instanceof TMouseEvent) {
+                 TMouseEvent mouse = (TMouseEvent) event;
+                 // Convert the mouse relative x/y to window coordinates
+                 assert (mouse.getX() == mouse.getAbsoluteX());
+                 assert (mouse.getY() == mouse.getAbsoluteY());
+                 mouse.setX(mouse.getX() - window.getX());
+                 mouse.setY(mouse.getY() - window.getY());
+                 if (doubleClick != null) {
+                     doubleClick.setX(doubleClick.getX() - window.getX());
+                     doubleClick.setY(doubleClick.getY() - window.getY());
+                 }
+                 if (window.mouseWouldHit(mouse)) {
+                     dispatchToDesktop = false;
+                 }
+             } else if (event instanceof TKeypressEvent) {
+                 dispatchToDesktop = false;
+             } else if (event instanceof TMenuEvent) {
+                 dispatchToDesktop = false;
+             }
+             if (debugEvents) {
+                 System.err.printf("TApplication dispatch event: %s\n",
+                     event);
+             }
+             window.handleEvent(event);
+             if (doubleClick != null) {
+                 window.handleEvent(doubleClick);
+             }
+         }
+         if (dispatchToDesktop) {
+             // This event is fair game for the desktop to process.
+             if (desktop != null) {
+                 desktop.handleEvent(event);
+                 if (doubleClick != null) {
+                     desktop.handleEvent(doubleClick);
+                 }
+             }
+         }
+     }
+     /**
+      * Dispatch one event to the appropriate widget or application-level
+      * event handler.  This is the secondary event handler used by certain
+      * special dialogs (currently TMessageBox and TFileOpenBox).
+      *
+      * @param event the input event to consume
+      * @see #primaryHandleEvent(TInputEvent event)
+      */
+     private void secondaryHandleEvent(final TInputEvent event) {
+         TMouseEvent doubleClick = null;
+         if (debugEvents) {
+             System.err.printf("%s secondaryHandleEvent: %s\n",
+                 Thread.currentThread(), event);
+         }
+         // Peek at the mouse position
+         if (event instanceof TMouseEvent) {
+             typingHidMouse = false;
+             TMouseEvent mouse = (TMouseEvent) event;
+             if ((mouseX != mouse.getX()) || (mouseY != mouse.getY())) {
+                 oldMouseX = mouseX;
+                 oldMouseY = mouseY;
+                 mouseX = mouse.getX();
+                 mouseY = mouse.getY();
+             } else {
+                 if ((mouse.getType() == TMouseEvent.Type.MOUSE_DOWN)
+                     && (!mouse.isMouseWheelUp())
+                     && (!mouse.isMouseWheelDown())
+                 ) {
+                     if ((mouse.getTime().getTime() - lastMouseUpTime) <
+                         doubleClickTime) {
+                         // This is a double-click.
+                         doubleClick = new TMouseEvent(TMouseEvent.Type.
+                             MOUSE_DOUBLE_CLICK,
+                             mouse.getX(), mouse.getY(),
+                             mouse.getAbsoluteX(), mouse.getAbsoluteY(),
+                             mouse.isMouse1(), mouse.isMouse2(),
+                             mouse.isMouse3(),
+                             mouse.isMouseWheelUp(), mouse.isMouseWheelDown());
+                     } else {
+                         // The first click of a potential double-click.
+                         lastMouseUpTime = mouse.getTime().getTime();
+                     }
+                 }
+             }
+         }
+         secondaryEventReceiver.handleEvent(event);
+         // Note that it is possible for secondaryEventReceiver to be null
+         // now, because its handleEvent() might have finished out on the
+         // secondary thread.  So put any extra processing inside a null
+         // check.
+         if (secondaryEventReceiver != null) {
+             if (doubleClick != null) {
+                 secondaryEventReceiver.handleEvent(doubleClick);
+             }
+         }
+     }
+     /**
+      * Enable a widget to override the primary event thread.
+      *
+      * @param widget widget that will receive events
+      */
+     public final void enableSecondaryEventReceiver(final TWidget widget) {
+         if (debugThreads) {
+             System.err.println(System.currentTimeMillis() +
+                 " enableSecondaryEventReceiver()");
+         }
+         assert (secondaryEventReceiver == null);
+         assert (secondaryEventHandler == null);
+         assert ((widget instanceof TMessageBox)
+             || (widget instanceof TFileOpenBox));
+         secondaryEventReceiver = widget;
+         secondaryEventHandler = new WidgetEventHandler(this, false);
+         (new Thread(secondaryEventHandler)).start();
+     }
+     /**
+      * Yield to the secondary thread.
+      */
+     public final void yield() {
+         if (debugThreads) {
+             System.err.printf(System.currentTimeMillis() + " " +
+                 Thread.currentThread() + " yield()\n");
+         }
+         assert (secondaryEventReceiver != null);
+         while (secondaryEventReceiver != null) {
+             synchronized (primaryEventHandler) {
+                 try {
+                     primaryEventHandler.wait();
+                 } catch (InterruptedException e) {
+                     // SQUASH
+                 }
+             }
+         }
+     }
+     /**
+      * Do stuff when there is no user input.
+      */
+     private void doIdle() {
+         if (debugThreads) {
+             System.err.printf(System.currentTimeMillis() + " " +
+                 Thread.currentThread() + " doIdle()\n");
+         }
+         synchronized (timers) {
+             if (debugThreads) {
+                 System.err.printf(System.currentTimeMillis() + " " +
+                     Thread.currentThread() + " doIdle() 2\n");
+             }
+             // Run any timers that have timed out
+             Date now = new Date();
+             List<TTimer> keepTimers = new LinkedList<TTimer>();
+             for (TTimer timer: timers) {
+                 if (timer.getNextTick().getTime() <= now.getTime()) {
+                     // Something might change, so repaint the screen.
+                     repaint = true;
+                     timer.tick();
+                     if (timer.recurring) {
+                         keepTimers.add(timer);
+                     }
+                 } else {
+                     keepTimers.add(timer);
+                 }
+             }
+             timers.clear();
+             timers.addAll(keepTimers);
+         }
+         // Call onIdle's
+         for (TWindow window: windows) {
+             window.onIdle();
+         }
+         if (desktop != null) {
+             desktop.onIdle();
+         }
+         // Run any invokeLaters
+         synchronized (invokeLaters) {
+             for (Runnable invoke: invokeLaters) {
+                 invoke.run();
+             }
+             invokeLaters.clear();
+         }
+     }
+     /**
+      * Wake the sleeping active event handler.
+      */
+     private void wakeEventHandler() {
+         if (!started) {
+             return;
+         }
+         if (secondaryEventHandler != null) {
+             synchronized (secondaryEventHandler) {
+                 secondaryEventHandler.notify();
+             }
+         } else {
+             assert (primaryEventHandler != null);
+             synchronized (primaryEventHandler) {
+                 primaryEventHandler.notify();
+             }
+         }
+     }
+     /**
+      * Wake the sleeping screen handler.
+      */
+     private void wakeScreenHandler() {
+         if (!started) {
+             return;
+         }
+         synchronized (screenHandler) {
+             screenHandler.notify();
+         }
+     }
+     // ------------------------------------------------------------------------
+     // TApplication -----------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Place a command on the run queue, and run it before the next round of
+      * checking I/O.
+      *
+      * @param command the command to run later
+      */
+     public void invokeLater(final Runnable command) {
+         synchronized (invokeLaters) {
+             invokeLaters.add(command);
+         }
+         doRepaint();
+     }
+     /**
+      * Restore the console to sane defaults.  This is meant to be used for
+      * improper exits (e.g. a caught exception in main()), and should not be
+      * necessary for normal program termination.
+      */
+     public void restoreConsole() {
+         if (backend != null) {
+             if (backend instanceof ECMA48Backend) {
+                 backend.shutdown();
+             }
+         }
+     }
+     /**
+      * Get the Backend.
+      *
+      * @return the Backend
+      */
+     public final Backend getBackend() {
+         return backend;
+     }
+     /**
+      * Get the Screen.
+      *
+      * @return the Screen
+      */
+     public final Screen getScreen() {
+         if (backend instanceof TWindowBackend) {
+             // We are being rendered to a TWindow.  We can't use its
+             // getScreen() method because that is how it is rendering to a
+             // hardware backend somewhere.  Instead use its getOtherScreen()
+             // method.
+             return ((TWindowBackend) backend).getOtherScreen();
+         } else {
+             return backend.getScreen();
+         }
+     }
+     /**
+      * Get the color theme.
+      *
+      * @return the theme
+      */
+     public final ColorTheme getTheme() {
+         return theme;
+     }
+     /**
+      * Repaint the screen on the next update.
+      */
+     public void doRepaint() {
+         repaint = true;
+         wakeEventHandler();
+     }
+     /**
+      * Get Y coordinate of the top edge of the desktop.
+      *
+      * @return Y coordinate of the top edge of the desktop
+      */
+     public final int getDesktopTop() {
+         return desktopTop;
+     }
+     /**
+      * Get Y coordinate of the bottom edge of the desktop.
+      *
+      * @return Y coordinate of the bottom edge of the desktop
+      */
+     public final int getDesktopBottom() {
+         return desktopBottom;
+     }
+     /**
+      * Set the TDesktop instance.
+      *
+      * @param desktop a TDesktop instance, or null to remove the one that is
+      * set
+      */
+     public final void setDesktop(final TDesktop desktop) {
+         if (this.desktop != null) {
+             this.desktop.onPreClose();
+             this.desktop.onUnfocus();
+             this.desktop.onClose();
+         }
+         this.desktop = desktop;
+     }
+     /**
+      * Get the TDesktop instance.
+      *
+      * @return the desktop, or null if it is not set
+      */
+     public final TDesktop getDesktop() {
+         return desktop;
+     }
+     /**
+      * Get the current active window.
+      *
+      * @return the active window, or null if it is not set
+      */
+     public final TWindow getActiveWindow() {
+         return activeWindow;
+     }
+     /**
+      * Get a (shallow) copy of the window list.
+      *
+      * @return a copy of the list of windows for this application
+      */
+     public final List<TWindow> getAllWindows() {
+         List<TWindow> result = new ArrayList<TWindow>();
+         result.addAll(windows);
+         return result;
+     }
+     /**
+      * Get focusFollowsMouse flag.
+      *
+      * @return true if focus follows mouse: windows automatically raised if
+      * the mouse passes over them
+      */
+     public boolean getFocusFollowsMouse() {
+         return focusFollowsMouse;
+     }
+     /**
+      * Set focusFollowsMouse flag.
+      *
+      * @param focusFollowsMouse if true, focus follows mouse: windows
+      * automatically raised if the mouse passes over them
+      */
+     public void setFocusFollowsMouse(final boolean focusFollowsMouse) {
+         this.focusFollowsMouse = focusFollowsMouse;
+     }
+     /**
+      * Display the about dialog.
+      */
+     protected void showAboutDialog() {
+         String version = getClass().getPackage().getImplementationVersion();
+         if (version == null) {
+             // This is Java 9+, use a hardcoded string here.
+             version = "0.3.2";
+         }
+         messageBox(i18n.getString("aboutDialogTitle"),
+             MessageFormat.format(i18n.getString("aboutDialogText"), version),
+             TMessageBox.Type.OK);
+     }
+     /**
+      * Handle the Tool | Open image menu item.
+      */
+     private void openImage() {
+         try {
+             List<String> filters = new ArrayList<String>();
+             filters.add("^.*\\.[Jj][Pp][Gg]$");
+             filters.add("^.*\\.[Jj][Pp][Ee][Gg]$");
+             filters.add("^.*\\.[Pp][Nn][Gg]$");
+             filters.add("^.*\\.[Gg][Ii][Ff]$");
+             filters.add("^.*\\.[Bb][Mm][Pp]$");
+             String filename = fileOpenBox(".", TFileOpenBox.Type.OPEN, filters);
+             if (filename != null) {
+                 new TImageWindow(this, new File(filename));
+             }
+         } catch (IOException e) {
+             // Show this exception to the user.
+             new TExceptionDialog(this, e);
+         }
+     }
+     /**
+      * Check if application is still running.
+      *
+      * @return true if the application is running
+      */
+     public final boolean isRunning() {
+         if (quit == true) {
+             return false;
+         }
+         return true;
+     }
+     // ------------------------------------------------------------------------
+     // Screen refresh loop ----------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Invert the cell color at a position.  This is used to track the mouse.
+      *
+      * @param x column position
+      * @param y row position
+      */
+     private void invertCell(final int x, final int y) {
+         invertCell(x, y, false);
+     }
+     /**
+      * Invert the cell color at a position.  This is used to track the mouse.
+      *
+      * @param x column position
+      * @param y row position
+      * @param onlyThisCell if true, only invert this cell
+      */
+     private void invertCell(final int x, final int y,
+         final boolean onlyThisCell) {
+         if (debugThreads) {
+             System.err.printf("%d %s invertCell() %d %d\n",
+                 System.currentTimeMillis(), Thread.currentThread(), x, y);
+             if (activeWindow != null) {
+                 System.err.println("activeWindow.hasHiddenMouse() " +
+                     activeWindow.hasHiddenMouse());
+             }
+         }
+         // If this cell is on top of a visible window that has requested a
+         // hidden mouse, bail out.
+         if ((activeWindow != null) && (activeMenu == null)) {
+             if ((activeWindow.hasHiddenMouse() == true)
+                 && (x > activeWindow.getX())
+                 && (x < activeWindow.getX() + activeWindow.getWidth() - 1)
+                 && (y > activeWindow.getY())
+                 && (y < activeWindow.getY() + activeWindow.getHeight() - 1)
+             ) {
+                 return;
+             }
+         }
+         // If this cell is on top of the desktop, and the desktop has
+         // requested a hidden mouse, bail out.
+         if ((desktop != null) && (activeWindow == null) && (activeMenu == null)) {
+             if ((desktop.hasHiddenMouse() == true)
+                 && (x > desktop.getX())
+                 && (x < desktop.getX() + desktop.getWidth() - 1)
+                 && (y > desktop.getY())
+                 && (y < desktop.getY() + desktop.getHeight() - 1)
+             ) {
+                 return;
+             }
+         }
+         Cell cell = getScreen().getCharXY(x, y);
+         if (cell.isImage()) {
+             cell.invertImage();
+         }
+         if (cell.getForeColorRGB() < 0) {
+             cell.setForeColor(cell.getForeColor().invert());
+         } else {
+             cell.setForeColorRGB(cell.getForeColorRGB() ^ 0x00ffffff);
+         }
+         if (cell.getBackColorRGB() < 0) {
+             cell.setBackColor(cell.getBackColor().invert());
+         } else {
+             cell.setBackColorRGB(cell.getBackColorRGB() ^ 0x00ffffff);
+         }
+         getScreen().putCharXY(x, y, cell);
+         if ((onlyThisCell == true) || (cell.getWidth() == Cell.Width.SINGLE)) {
+             return;
+         }
+         // This cell is one half of a fullwidth glyph.  Invert the other
+         // half.
+         if (cell.getWidth() == Cell.Width.LEFT) {
+             if (x < getScreen().getWidth() - 1) {
+                 Cell rightHalf = getScreen().getCharXY(x + 1, y);
+                 if (rightHalf.getWidth() == Cell.Width.RIGHT) {
+                     invertCell(x + 1, y, true);
+                     return;
+                 }
+             }
+         }
+         if (cell.getWidth() == Cell.Width.RIGHT) {
+             if (x > 0) {
+                 Cell leftHalf = getScreen().getCharXY(x - 1, y);
+                 if (leftHalf.getWidth() == Cell.Width.LEFT) {
+                     invertCell(x - 1, y, true);
+                 }
+             }
+         }
+     }
+     /**
+      * Draw everything.
+      */
+     private void drawAll() {
+         boolean menuIsActive = false;
+         if (debugThreads) {
+             System.err.printf("%d %s drawAll() enter\n",
+                 System.currentTimeMillis(), Thread.currentThread());
+         }
+         // I don't think this does anything useful anymore...
+         if (!repaint) {
+             if (debugThreads) {
+                 System.err.printf("%d %s drawAll() !repaint\n",
+                     System.currentTimeMillis(), Thread.currentThread());
+             }
+             if ((oldDrawnMouseX != mouseX) || (oldDrawnMouseY != mouseY)) {
+                 if (debugThreads) {
+                     System.err.printf("%d %s drawAll() !repaint MOUSE\n",
+                         System.currentTimeMillis(), Thread.currentThread());
+                 }
+                 // The only thing that has happened is the mouse moved.
+                 // Redraw the old cell at that position, and save the cell at
+                 // the new mouse position.
+                 if (debugThreads) {
+                     System.err.printf("%d %s restoreImage() %d %d\n",
+                         System.currentTimeMillis(), Thread.currentThread(),
+                         oldDrawnMouseX, oldDrawnMouseY);
+                 }
+                 oldDrawnMouseCell.restoreImage();
+                 getScreen().putCharXY(oldDrawnMouseX, oldDrawnMouseY,
+                     oldDrawnMouseCell);
+                 oldDrawnMouseCell = getScreen().getCharXY(mouseX, mouseY);
+                 if (backend instanceof ECMA48Backend) {
+                     // Special case: the entire row containing the mouse has
+                     // to be re-drawn if it has any image data, AND any rows
+                     // in between.
+                     if (oldDrawnMouseY != mouseY) {
+                         for (int i = oldDrawnMouseY; ;) {
+                             getScreen().unsetImageRow(i);
+                             if (i == mouseY) {
+                                 break;
+                             }
+                             if (oldDrawnMouseY < mouseY) {
+                                 i++;
+                             } else {
+                                 i--;
+                             }
+                         }
+                     } else {
+                         getScreen().unsetImageRow(mouseY);
+                     }
+                 }
+                 if ((textMouse == true) && (typingHidMouse == false)) {
+                     // Draw mouse at the new position.
+                     invertCell(mouseX, mouseY);
+                 }
+                 oldDrawnMouseX = mouseX;
+                 oldDrawnMouseY = mouseY;
+             }
+             if (getScreen().isDirty()) {
+                 screenHandler.setDirty();
+             }
+             return;
+         }
+         if (debugThreads) {
+             System.err.printf("%d %s drawAll() REDRAW\n",
+                 System.currentTimeMillis(), Thread.currentThread());
+         }
+         // If true, the cursor is not visible
+         boolean cursor = false;
+         // Start with a clean screen
+         getScreen().clear();
+         // Draw the desktop
+         if (desktop != null) {
+             desktop.drawChildren();
+         }
+         // Draw each window in reverse Z order
+         List<TWindow> sorted = new ArrayList<TWindow>(windows);
+         Collections.sort(sorted);
+         TWindow topLevel = null;
+         if (sorted.size() > 0) {
+             topLevel = sorted.get(0);
+         }
+         Collections.reverse(sorted);
+         for (TWindow window: sorted) {
+             if (window.isShown()) {
+                 window.drawChildren();
+             }
+         }
+         if (hideMenuBar == false) {
+             // Draw the blank menubar line - reset the screen clipping first
+             // so it won't trim it out.
+             getScreen().resetClipping();
+             getScreen().hLineXY(0, 0, getScreen().getWidth(), ' ',
+                 theme.getColor("tmenu"));
+             // Now draw the menus.
+             int x = 1;
+             for (TMenu menu: menus) {
+                 CellAttributes menuColor;
+                 CellAttributes menuMnemonicColor;
+                 if (menu.isActive()) {
+                     menuIsActive = true;
+                     menuColor = theme.getColor("tmenu.highlighted");
+                     menuMnemonicColor = theme.getColor("tmenu.mnemonic.highlighted");
+                     topLevel = menu;
+                 } else {
+                     menuColor = theme.getColor("tmenu");
+                     menuMnemonicColor = theme.getColor("tmenu.mnemonic");
+                 }
+                 // Draw the menu title
+                 getScreen().hLineXY(x, 0,
+                     StringUtils.width(menu.getTitle()) + 2, ' ', menuColor);
+                 getScreen().putStringXY(x + 1, 0, menu.getTitle(), menuColor);
+                 // Draw the highlight character
+                 getScreen().putCharXY(x + 1 +
+                     menu.getMnemonic().getScreenShortcutIdx(),
+                     0, menu.getMnemonic().getShortcut(), menuMnemonicColor);
+                 if (menu.isActive()) {
+                     ((TWindow) menu).drawChildren();
+                     // Reset the screen clipping so we can draw the next
+                     // title.
+                     getScreen().resetClipping();
+                 }
+                 x += StringUtils.width(menu.getTitle()) + 2;
+             }
+             for (TMenu menu: subMenus) {
+                 // Reset the screen clipping so we can draw the next
+                 // sub-menu.
+                 getScreen().resetClipping();
+                 ((TWindow) menu).drawChildren();
+             }
+         }
+         getScreen().resetClipping();
+         if (hideStatusBar == false) {
+             // Draw the status bar of the top-level window
+             TStatusBar statusBar = null;
+             if (topLevel != null) {
+                 statusBar = topLevel.getStatusBar();
+             }
+             if (statusBar != null) {
+                 getScreen().resetClipping();
+                 statusBar.setWidth(getScreen().getWidth());
+                 statusBar.setY(getScreen().getHeight() - topLevel.getY());
+                 statusBar.draw();
+             } else {
+                 CellAttributes barColor = new CellAttributes();
+                 barColor.setTo(getTheme().getColor("tstatusbar.text"));
+                 getScreen().hLineXY(0, desktopBottom, getScreen().getWidth(),
+                     ' ', barColor);
+             }
+         }
+         // Draw the mouse pointer
+         if (debugThreads) {
+             System.err.printf("%d %s restoreImage() %d %d\n",
+                 System.currentTimeMillis(), Thread.currentThread(),
+                 oldDrawnMouseX, oldDrawnMouseY);
+         }
+         oldDrawnMouseCell = getScreen().getCharXY(mouseX, mouseY);
+         if (backend instanceof ECMA48Backend) {
+             // Special case: the entire row containing the mouse has to be
+             // re-drawn if it has any image data, AND any rows in between.
+             if (oldDrawnMouseY != mouseY) {
+                 for (int i = oldDrawnMouseY; ;) {
+                     getScreen().unsetImageRow(i);
+                     if (i == mouseY) {
+                         break;
+                     }
+                     if (oldDrawnMouseY < mouseY) {
+                         i++;
+                     } else {
+                         i--;
+                     }
+                 }
+             } else {
+                 getScreen().unsetImageRow(mouseY);
+             }
+         }
+         if ((textMouse == true) && (typingHidMouse == false)) {
+             invertCell(mouseX, mouseY);
+         }
+         oldDrawnMouseX = mouseX;
+         oldDrawnMouseY = mouseY;
+         // Place the cursor if it is visible
+         if (!menuIsActive) {
+             int visibleWindowCount = 0;
+             for (TWindow window: sorted) {
+                 if (window.isShown()) {
+                     visibleWindowCount++;
+                 }
+             }
+             if (visibleWindowCount == 0) {
+                 // No windows are visible, only the desktop.  Allow it to
+                 // have the cursor.
+                 if (desktop != null) {
+                     sorted.add(desktop);
+                 }
+             }
+             TWidget activeWidget = null;
+             if (sorted.size() > 0) {
+                 activeWidget = sorted.get(sorted.size() - 1).getActiveChild();
+                 int cursorClipTop = desktopTop;
+                 int cursorClipBottom = desktopBottom;
+                 if (activeWidget.isCursorVisible()) {
+                     if ((activeWidget.getCursorAbsoluteY() <= cursorClipBottom)
+                         && (activeWidget.getCursorAbsoluteY() >= cursorClipTop)
+                     ) {
+                         getScreen().putCursor(true,
+                             activeWidget.getCursorAbsoluteX(),
+                             activeWidget.getCursorAbsoluteY());
+                         cursor = true;
+                     } else {
+                         // Turn off the cursor.  Also place it at 0,0.
+                         getScreen().putCursor(false, 0, 0);
+                         cursor = false;
+                     }
+                 }
+             }
+         }
+         // Kill the cursor
+         if (!cursor) {
+             getScreen().hideCursor();
+         }
+         if (getScreen().isDirty()) {
+             screenHandler.setDirty();
+         }
+         repaint = false;
+     }
+     /**
+      * Force this application to exit.
+      */
+     public void exit() {
+         quit = true;
+         synchronized (this) {
+             this.notify();
+         }
+     }
+     /**
+      * Subclasses can use this hook to cleanup resources.  Called as the last
+      * step of TApplication.run().
+      */
+     public void onExit() {
+         // Default does nothing.
+     }
+     // ------------------------------------------------------------------------
+     // TWindow management -----------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Return the total number of windows.
+      *
+      * @return the total number of windows
+      */
+     public final int windowCount() {
+         return windows.size();
+     }
+     /**
+      * Return the number of windows that are showing.
+      *
+      * @return the number of windows that are showing on screen
+      */
+     public final int shownWindowCount() {
+         int n = 0;
+         for (TWindow w: windows) {
+             if (w.isShown()) {
+                 n++;
+             }
+         }
+         return n;
+     }
+     /**
+      * Return the number of windows that are hidden.
+      *
+      * @return the number of windows that are hidden
+      */
+     public final int hiddenWindowCount() {
+         int n = 0;
+         for (TWindow w: windows) {
+             if (w.isHidden()) {
+                 n++;
+             }
+         }
+         return n;
+     }
+     /**
+      * Check if a window instance is in this application's window list.
+      *
+      * @param window window to look for
+      * @return true if this window is in the list
+      */
+     public final boolean hasWindow(final TWindow window) {
+         if (windows.size() == 0) {
+             return false;
+         }
+         for (TWindow w: windows) {
+             if (w == window) {
+                 assert (window.getApplication() == this);
+                 return true;
+             }
+         }
+         return false;
+     }
+     /**
+      * Activate a window: bring it to the top and have it receive events.
+      *
+      * @param window the window to become the new active window
+      */
+     public void activateWindow(final TWindow window) {
+         if (hasWindow(window) == false) {
+             /*
+              * Someone has a handle to a window I don't have.  Ignore this
+              * request.
+              */
+             return;
+         }
+         // Whatever window might be moving/dragging, stop it now.
+         for (TWindow w: windows) {
+             if (w.inMovements()) {
+                 w.stopMovements();
+             }
+         }
+         assert (windows.size() > 0);
+         if (window.isHidden()) {
+             // Unhiding will also activate.
+             showWindow(window);
+             return;
+         }
+         assert (window.isShown());
+         if (windows.size() == 1) {
+             assert (window == windows.get(0));
+             if (activeWindow == null) {
+                 activeWindow = window;
+                 window.setZ(0);
+                 activeWindow.setActive(true);
+                 activeWindow.onFocus();
+             }
+             assert (window.isActive());
+             assert (activeWindow == window);
+             return;
+         }
+         if (activeWindow == window) {
+             assert (window.isActive());
+             // Window is already active, do nothing.
+             return;
+         }
+         assert (!window.isActive());
+         if (activeWindow != null) {
+             activeWindow.setActive(false);
+             // Increment every window Z that is on top of window
+             for (TWindow w: windows) {
+                 if (w == window) {
+                     continue;
+                 }
+                 if (w.getZ() < window.getZ()) {
+                     w.setZ(w.getZ() + 1);
+                 }
+             }
+             // Unset activeWindow now before unfocus, so that a window
+             // lifecycle change inside onUnfocus() doesn't call
+             // switchWindow() and lead to a stack overflow.
+             TWindow oldActiveWindow = activeWindow;
+             activeWindow = null;
+             oldActiveWindow.onUnfocus();
+         }
+         activeWindow = window;
+         activeWindow.setZ(0);
+         activeWindow.setActive(true);
+         activeWindow.onFocus();
+         return;
+     }
+     /**
+      * Hide a window.
+      *
+      * @param window the window to hide
+      */
+     public void hideWindow(final TWindow window) {
+         if (hasWindow(window) == false) {
+             /*
+              * Someone has a handle to a window I don't have.  Ignore this
+              * request.
+              */
+             return;
+         }
+         // Whatever window might be moving/dragging, stop it now.
+         for (TWindow w: windows) {
+             if (w.inMovements()) {
+                 w.stopMovements();
+             }
+         }
+         assert (windows.size() > 0);
+         if (!window.hidden) {
+             if (window == activeWindow) {
+                 if (shownWindowCount() > 1) {
+                     switchWindow(true);
+                 } else {
+                     activeWindow = null;
+                     window.setActive(false);
+                     window.onUnfocus();
+                 }
+             }
+             window.hidden = true;
+             window.onHide();
+         }
+     }
+     /**
+      * Show a window.
+      *
+      * @param window the window to show
+      */
+     public void showWindow(final TWindow window) {
+         if (hasWindow(window) == false) {
+             /*
+              * Someone has a handle to a window I don't have.  Ignore this
+              * request.
+              */
+             return;
+         }
+         // Whatever window might be moving/dragging, stop it now.
+         for (TWindow w: windows) {
+             if (w.inMovements()) {
+                 w.stopMovements();
+             }
+         }
+         assert (windows.size() > 0);
+         if (window.hidden) {
+             window.hidden = false;
+             window.onShow();
+             activateWindow(window);
+         }
+     }
+     /**
+      * Close window.  Note that the window's destructor is NOT called by this
+      * method, instead the GC is assumed to do the cleanup.
+      *
+      * @param window the window to remove
+      */
+     public final void closeWindow(final TWindow window) {
+         if (hasWindow(window) == false) {
+             /*
+              * Someone has a handle to a window I don't have.  Ignore this
+              * request.
+              */
+             return;
+         }
+         // Let window know that it is about to be closed, while it is still
+         // visible on screen.
+         window.onPreClose();
+         synchronized (windows) {
+             // Whatever window might be moving/dragging, stop it now.
+             for (TWindow w: windows) {
+                 if (w.inMovements()) {
+                     w.stopMovements();
+                 }
+             }
+             int z = window.getZ();
+             window.setZ(-1);
+             window.onUnfocus();
+             windows.remove(window);
+             Collections.sort(windows);
+             activeWindow = null;
+             int newZ = 0;
+             boolean foundNextWindow = false;
+             for (TWindow w: windows) {
+                 w.setZ(newZ);
+                 newZ++;
+                 // Do not activate a hidden window.
+                 if (w.isHidden()) {
+                     continue;
+                 }
+                 if (foundNextWindow == false) {
+                     foundNextWindow = true;
+                     w.setActive(true);
+                     w.onFocus();
+                     assert (activeWindow == null);
+                     activeWindow = w;
+                     continue;
+                 }
+                 if (w.isActive()) {
+                     w.setActive(false);
+                     w.onUnfocus();
+                 }
+             }
+         }
+         // Perform window cleanup
+         window.onClose();
+         // Check if we are closing a TMessageBox or similar
+         if (secondaryEventReceiver != null) {
+             assert (secondaryEventHandler != null);
+             // Do not send events to the secondaryEventReceiver anymore, the
+             // window is closed.
+             secondaryEventReceiver = null;
+             // Wake the secondary thread, it will wake the primary as it
+             // exits.
+             synchronized (secondaryEventHandler) {
+                 secondaryEventHandler.notify();
+             }
+         }
+         // Permit desktop to be active if it is the only thing left.
+         if (desktop != null) {
+             if (windows.size() == 0) {
+                 desktop.setActive(true);
+             }
+         }
+     }
+     /**
+      * Switch to the next window.
+      *
+      * @param forward if true, then switch to the next window in the list,
+      * otherwise switch to the previous window in the list
+      */
+     public final void switchWindow(final boolean forward) {
+         // Only switch if there are multiple visible windows
+         if (shownWindowCount() < 2) {
+             return;
+         }
+         assert (activeWindow != null);
+         synchronized (windows) {
+             // Whatever window might be moving/dragging, stop it now.
+             for (TWindow w: windows) {
+                 if (w.inMovements()) {
+                     w.stopMovements();
+                 }
+             }
+             // Swap z/active between active window and the next in the list
+             int activeWindowI = -1;
+             for (int i = 0; i < windows.size(); i++) {
+                 if (windows.get(i) == activeWindow) {
+                     assert (activeWindow.isActive());
+                     activeWindowI = i;
+                     break;
+                 } else {
+                     assert (!windows.get(0).isActive());
+                 }
+             }
+             assert (activeWindowI >= 0);
+             // Do not switch if a window is modal
+             if (activeWindow.isModal()) {
+                 return;
+             }
+             int nextWindowI = activeWindowI;
+             for (;;) {
+                 if (forward) {
+                     nextWindowI++;
+                     nextWindowI %= windows.size();
+                 } else {
+                     nextWindowI--;
+                     if (nextWindowI < 0) {
+                         nextWindowI = windows.size() - 1;
+                     }
+                 }
+                 if (windows.get(nextWindowI).isShown()) {
+                     activateWindow(windows.get(nextWindowI));
+                     break;
+                 }
+             }
+         } // synchronized (windows)
+     }
+     /**
+      * Add a window to my window list and make it active.  Note package
+      * private access.
+      *
+      * @param window new window to add
+      */
+     final void addWindowToApplication(final TWindow window) {
+         // Do not add menu windows to the window list.
+         if (window instanceof TMenu) {
+             return;
+         }
+         // Do not add the desktop to the window list.
+         if (window instanceof TDesktop) {
+             return;
+         }
+         synchronized (windows) {
+             if (windows.contains(window)) {
+                 throw new IllegalArgumentException("Window " + window +
+                     " is already in window list");
+             }
+             // Whatever window might be moving/dragging, stop it now.
+             for (TWindow w: windows) {
+                 if (w.inMovements()) {
+                     w.stopMovements();
+                 }
+             }
+             // Do not allow a modal window to spawn a non-modal window.  If a
+             // modal window is active, then this window will become modal
+             // too.
+             if (modalWindowActive()) {
+                 window.flags |= TWindow.MODAL;
+                 window.flags |= TWindow.CENTERED;
+                 window.hidden = false;
+             }
+             if (window.isShown()) {
+                 for (TWindow w: windows) {
+                     if (w.isActive()) {
+                         w.setActive(false);
+                         w.onUnfocus();
+                     }
+                     w.setZ(w.getZ() + 1);
+                 }
+             }
+             windows.add(window);
+             if (window.isShown()) {
+                 activeWindow = window;
+                 activeWindow.setZ(0);
+                 activeWindow.setActive(true);
+                 activeWindow.onFocus();
+             }
+             if (((window.flags & TWindow.CENTERED) == 0)
+                 && ((window.flags & TWindow.ABSOLUTEXY) == 0)
+                 && (smartWindowPlacement == true)
+                 && (!(window instanceof TDesktop))
+             ) {
+                 doSmartPlacement(window);
+             }
+         }
+         // Desktop cannot be active over any other window.
+         if (desktop != null) {
+             desktop.setActive(false);
+         }
+     }
+     /**
+      * Check if there is a system-modal window on top.
+      *
+      * @return true if the active window is modal
+      */
+     private boolean modalWindowActive() {
+         if (windows.size() == 0) {
+             return false;
+         }
+         for (TWindow w: windows) {
+             if (w.isModal()) {
+                 return true;
+             }
+         }
+         return false;
+     }
+     /**
+      * Check if there is a window with overridden menu flag on top.
+      *
+      * @return true if the active window is overriding the menu
+      */
+     private boolean overrideMenuWindowActive() {
+         if (activeWindow != null) {
+             if (activeWindow.hasOverriddenMenu()) {
+                 return true;
+             }
+         }
+         return false;
+     }
+     /**
+      * Close all open windows.
+      */
+     private void closeAllWindows() {
+         // Don't do anything if we are in the menu
+         if (activeMenu != null) {
+             return;
+         }
+         while (windows.size() > 0) {
+             closeWindow(windows.get(0));
+         }
+     }
+     /**
+      * Re-layout the open windows as non-overlapping tiles.  This produces
+      * almost the same results as Turbo Pascal 7.0's IDE.
+      */
+     private void tileWindows() {
+         synchronized (windows) {
+             // Don't do anything if we are in the menu
+             if (activeMenu != null) {
+                 return;
+             }
+             int z = windows.size();
+             if (z == 0) {
+                 return;
+             }
+             int a = 0;
+             int b = 0;
+             a = (int)(Math.sqrt(z));
+             int c = 0;
+             while (c < a) {
+                 b = (z - c) / a;
+                 if (((a * b) + c) == z) {
+                     break;
+                 }
+                 c++;
+             }
+             assert (a > 0);
+             assert (b > 0);
+             assert (c < a);
+             int newWidth = (getScreen().getWidth() / a);
+             int newHeight1 = ((getScreen().getHeight() - 1) / b);
+             int newHeight2 = ((getScreen().getHeight() - 1) / (b + c));
+             List<TWindow> sorted = new ArrayList<TWindow>(windows);
+             Collections.sort(sorted);
+             Collections.reverse(sorted);
+             for (int i = 0; i < sorted.size(); i++) {
+                 int logicalX = i / b;
+                 int logicalY = i % b;
+                 if (i >= ((a - 1) * b)) {
+                     logicalX = a - 1;
+                     logicalY = i - ((a - 1) * b);
+                 }
+                 TWindow w = sorted.get(i);
+                 int oldWidth = w.getWidth();
+                 int oldHeight = w.getHeight();
+                 w.setX(logicalX * newWidth);
+                 w.setWidth(newWidth);
+                 if (i >= ((a - 1) * b)) {
+                     w.setY((logicalY * newHeight2) + 1);
+                     w.setHeight(newHeight2);
+                 } else {
+                     w.setY((logicalY * newHeight1) + 1);
+                     w.setHeight(newHeight1);
+                 }
+                 if ((w.getWidth() != oldWidth)
+                     || (w.getHeight() != oldHeight)
+                 ) {
+                     w.onResize(new TResizeEvent(TResizeEvent.Type.WIDGET,
+                             w.getWidth(), w.getHeight()));
+                 }
+             }
+         }
+     }
+     /**
+      * Re-layout the open windows as overlapping cascaded windows.
+      */
+     private void cascadeWindows() {
+         synchronized (windows) {
+             // Don't do anything if we are in the menu
+             if (activeMenu != null) {
+                 return;
+             }
+             int x = 0;
+             int y = 1;
+             List<TWindow> sorted = new ArrayList<TWindow>(windows);
+             Collections.sort(sorted);
+             Collections.reverse(sorted);
+             for (TWindow window: sorted) {
+                 window.setX(x);
+                 window.setY(y);
+                 x++;
+                 y++;
+                 if (x > getScreen().getWidth()) {
+                     x = 0;
+                 }
+                 if (y >= getScreen().getHeight()) {
+                     y = 1;
+                 }
+             }
+         }
+     }
+     /**
+      * Place a window to minimize its overlap with other windows.
+      *
+      * @param window the window to place
+      */
+     public final void doSmartPlacement(final TWindow window) {
+         // This is a pretty dumb algorithm, but seems to work.  The hardest
+         // part is computing these "overlap" values seeking a minimum average
+         // overlap.
+         int xMin = 0;
+         int yMin = desktopTop;
+         int xMax = getScreen().getWidth() - window.getWidth() + 1;
+         int yMax = desktopBottom  - window.getHeight() + 1;
+         if (xMax < xMin) {
+             xMax = xMin;
+         }
+         if (yMax < yMin) {
+             yMax = yMin;
+         }
+         if ((xMin == xMax) && (yMin == yMax)) {
+             // No work to do, bail out.
+             return;
+         }
+         // Compute the overlap matrix without the new window.
+         int width = getScreen().getWidth();
+         int height = getScreen().getHeight();
+         int overlapMatrix[][] = new int[width][height];
+         for (TWindow w: windows) {
+             if (window == w) {
+                 continue;
+             }
+             for (int x = w.getX(); x < w.getX() + w.getWidth(); x++) {
+                 if (x < 0) {
+                     continue;
+                 }
+                 if (x >= width) {
+                     continue;
+                 }
+                 for (int y = w.getY(); y < w.getY() + w.getHeight(); y++) {
+                     if (y < 0) {
+                         continue;
+                     }
+                     if (y >= height) {
+                         continue;
+                     }
+                     overlapMatrix[x][y]++;
+                 }
+             }
+         }
+         long oldOverlapTotal = 0;
+         long oldOverlapN = 0;
+         for (int x = 0; x < width; x++) {
+             for (int y = 0; y < height; y++) {
+                 oldOverlapTotal += overlapMatrix[x][y];
+                 if (overlapMatrix[x][y] > 0) {
+                     oldOverlapN++;
+                 }
+             }
+         }
+         double oldOverlapAvg = (double) oldOverlapTotal / (double) oldOverlapN;
+         boolean first = true;
+         int windowX = window.getX();
+         int windowY = window.getY();
+         // For each possible (x, y) position for the new window, compute a
+         // new overlap matrix.
+         for (int x = xMin; x < xMax; x++) {
+             for (int y = yMin; y < yMax; y++) {
+                 // Start with the matrix minus this window.
+                 int newMatrix[][] = new int[width][height];
+                 for (int mx = 0; mx < width; mx++) {
+                     for (int my = 0; my < height; my++) {
+                         newMatrix[mx][my] = overlapMatrix[mx][my];
+                     }
+                 }
+                 // Add this window's values to the new overlap matrix.
+                 long newOverlapTotal = 0;
+                 long newOverlapN = 0;
+                 // Start by adding each new cell.
+                 for (int wx = x; wx < x + window.getWidth(); wx++) {
+                     if (wx >= width) {
+                         continue;
+                     }
+                     for (int wy = y; wy < y + window.getHeight(); wy++) {
+                         if (wy >= height) {
+                             continue;
+                         }
+                         newMatrix[wx][wy]++;
+                     }
+                 }
+                 // Now figure out the new value for total coverage.
+                 for (int mx = 0; mx < width; mx++) {
+                     for (int my = 0; my < height; my++) {
+                         newOverlapTotal += newMatrix[x][y];
+                         if (newMatrix[mx][my] > 0) {
+                             newOverlapN++;
+                         }
+                     }
+                 }
+                 double newOverlapAvg = (double) newOverlapTotal / (double) newOverlapN;
+                 if (first) {
+                     // First time: just record what we got.
+                     oldOverlapAvg = newOverlapAvg;
+                     first = false;
+                 } else {
+                     // All other times: pick a new best (x, y) and save the
+                     // overlap value.
+                     if (newOverlapAvg < oldOverlapAvg) {
+                         windowX = x;
+                         windowY = y;
+                         oldOverlapAvg = newOverlapAvg;
+                     }
+                 }
+             } // for (int x = xMin; x < xMax; x++)
+         } // for (int y = yMin; y < yMax; y++)
+         // Finally, set the window's new coordinates.
+         window.setX(windowX);
+         window.setY(windowY);
+     }
+     // ------------------------------------------------------------------------
+     // TMenu management -------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Check if a mouse event would hit either the active menu or any open
+      * sub-menus.
+      *
+      * @param mouse mouse event
+      * @return true if the mouse would hit the active menu or an open
+      * sub-menu
+      */
+     private boolean mouseOnMenu(final TMouseEvent mouse) {
+         assert (activeMenu != null);
+         List<TMenu> menus = new ArrayList<TMenu>(subMenus);
+         Collections.reverse(menus);
+         for (TMenu menu: menus) {
+             if (menu.mouseWouldHit(mouse)) {
+                 return true;
+             }
+         }
+         return activeMenu.mouseWouldHit(mouse);
+     }
+     /**
+      * See if we need to switch window or activate the menu based on
+      * a mouse click.
+      *
+      * @param mouse mouse event
+      */
+     private void checkSwitchFocus(final TMouseEvent mouse) {
+         if ((mouse.getType() == TMouseEvent.Type.MOUSE_DOWN)
+             && (activeMenu != null)
+             && (mouse.getAbsoluteY() != 0)
+             && (!mouseOnMenu(mouse))
+         ) {
+             // They clicked outside the active menu, turn it off
+             activeMenu.setActive(false);
+             activeMenu = null;
+             for (TMenu menu: subMenus) {
+                 menu.setActive(false);
+             }
+             subMenus.clear();
+             // Continue checks
+         }
+         // See if they hit the menu bar
+         if ((mouse.getType() == TMouseEvent.Type.MOUSE_DOWN)
+             && (mouse.isMouse1())
+             && (!modalWindowActive())
+             && (!overrideMenuWindowActive())
+             && (mouse.getAbsoluteY() == 0)
+             && (hideMenuBar == false)
+         ) {
+             for (TMenu menu: subMenus) {
+                 menu.setActive(false);
+             }
+             subMenus.clear();
+             // They selected the menu, go activate it
+             for (TMenu menu: menus) {
+                 if ((mouse.getAbsoluteX() >= menu.getTitleX())
+                     && (mouse.getAbsoluteX() < menu.getTitleX()
+                         + StringUtils.width(menu.getTitle()) + 2)
+                 ) {
+                     menu.setActive(true);
+                     activeMenu = menu;
+                 } else {
+                     menu.setActive(false);
+                 }
+             }
+             return;
+         }
+         // See if they hit the menu bar
+         if ((mouse.getType() == TMouseEvent.Type.MOUSE_MOTION)
+             && (mouse.isMouse1())
+             && (activeMenu != null)
+             && (mouse.getAbsoluteY() == 0)
+             && (hideMenuBar == false)
+         ) {
+             TMenu oldMenu = activeMenu;
+             for (TMenu menu: subMenus) {
+                 menu.setActive(false);
+             }
+             subMenus.clear();
+             // See if we should switch menus
+             for (TMenu menu: menus) {
+                 if ((mouse.getAbsoluteX() >= menu.getTitleX())
+                     && (mouse.getAbsoluteX() < menu.getTitleX()
+                         + StringUtils.width(menu.getTitle()) + 2)
+                 ) {
+                     menu.setActive(true);
+                     activeMenu = menu;
+                 }
+             }
+             if (oldMenu != activeMenu) {
+                 // They switched menus
+                 oldMenu.setActive(false);
+             }
+             return;
+         }
+         // If a menu is still active, don't switch windows
+         if (activeMenu != null) {
+             return;
+         }
+         // Only switch if there are multiple windows
+         if (windows.size() < 2) {
+             return;
+         }
+         if (((focusFollowsMouse == true)
+                 && (mouse.getType() == TMouseEvent.Type.MOUSE_MOTION))
+             || (mouse.getType() == TMouseEvent.Type.MOUSE_DOWN)
+         ) {
+             synchronized (windows) {
+                 Collections.sort(windows);
+                 if (windows.get(0).isModal()) {
+                     // Modal windows don't switch
+                     return;
+                 }
+                 for (TWindow window: windows) {
+                     assert (!window.isModal());
+                     if (window.isHidden()) {
+                         assert (!window.isActive());
+                         continue;
+                     }
+                     if (window.mouseWouldHit(mouse)) {
+                         if (window == windows.get(0)) {
+                             // Clicked on the same window, nothing to do
+                             assert (window.isActive());
+                             return;
+                         }
+                         // We will be switching to another window
+                         assert (windows.get(0).isActive());
+                         assert (windows.get(0) == activeWindow);
+                         assert (!window.isActive());
+                         if (activeWindow != null) {
+                             activeWindow.onUnfocus();
+                             activeWindow.setActive(false);
+                             activeWindow.setZ(window.getZ());
+                         }
+                         activeWindow = window;
+                         window.setZ(0);
+                         window.setActive(true);
+                         window.onFocus();
+                         return;
+                     }
+                 }
+             }
+             // Clicked on the background, nothing to do
+             return;
+         }
+         // Nothing to do: this isn't a mouse up, or focus isn't following
+         // mouse.
+         return;
+     }
+     /**
+      * Turn off the menu.
+      */
+     public final void closeMenu() {
+         if (activeMenu != null) {
+             activeMenu.setActive(false);
+             activeMenu = null;
+             for (TMenu menu: subMenus) {
+                 menu.setActive(false);
+             }
+             subMenus.clear();
+         }
+     }
+     /**
+      * Get a (shallow) copy of the menu list.
+      *
+      * @return a copy of the menu list
+      */
+     public final List<TMenu> getAllMenus() {
+         return new ArrayList<TMenu>(menus);
+     }
+     /**
+      * Add a top-level menu to the list.
+      *
+      * @param menu the menu to add
+      * @throws IllegalArgumentException if the menu is already used in
+      * another TApplication
+      */
+     public final void addMenu(final TMenu menu) {
+         if ((menu.getApplication() != null)
+             && (menu.getApplication() != this)
+         ) {
+             throw new IllegalArgumentException("Menu " + menu + " is already " +
+                 "part of application " + menu.getApplication());
+         }
+         closeMenu();
+         menus.add(menu);
+         recomputeMenuX();
+     }
+     /**
+      * Remove a top-level menu from the list.
+      *
+      * @param menu the menu to remove
+      * @throws IllegalArgumentException if the menu is already used in
+      * another TApplication
+      */
+     public final void removeMenu(final TMenu menu) {
+         if ((menu.getApplication() != null)
+             && (menu.getApplication() != this)
+         ) {
+             throw new IllegalArgumentException("Menu " + menu + " is already " +
+                 "part of application " + menu.getApplication());
+         }
+         closeMenu();
+         menus.remove(menu);
+         recomputeMenuX();
+     }
+     /**
+      * Turn off a sub-menu.
+      */
+     public final void closeSubMenu() {
+         assert (activeMenu != null);
+         TMenu item = subMenus.get(subMenus.size() - 1);
+         assert (item != null);
+         item.setActive(false);
+         subMenus.remove(subMenus.size() - 1);
+     }
+     /**
+      * Switch to the next menu.
+      *
+      * @param forward if true, then switch to the next menu in the list,
+      * otherwise switch to the previous menu in the list
+      */
+     public final void switchMenu(final boolean forward) {
+         assert (activeMenu != null);
+         assert (hideMenuBar == false);
+         for (TMenu menu: subMenus) {
+             menu.setActive(false);
+         }
+         subMenus.clear();
+         for (int i = 0; i < menus.size(); i++) {
+             if (activeMenu == menus.get(i)) {
+                 if (forward) {
+                     if (i < menus.size() - 1) {
+                         i++;
+                     } else {
+                         i = 0;
+                     }
+                 } else {
+                     if (i > 0) {
+                         i--;
+                     } else {
+                         i = menus.size() - 1;
+                     }
+                 }
+                 activeMenu.setActive(false);
+                 activeMenu = menus.get(i);
+                 activeMenu.setActive(true);
+                 return;
+             }
+         }
+     }
+     /**
+      * Add a menu item to the global list.  If it has a keyboard accelerator,
+      * that will be added the global hash.
+      *
+      * @param item the menu item
+      */
+     public final void addMenuItem(final TMenuItem item) {
+         menuItems.add(item);
+         TKeypress key = item.getKey();
+         if (key != null) {
+             synchronized (accelerators) {
+                 assert (accelerators.get(key) == null);
+                 accelerators.put(key.toLowerCase(), item);
+             }
+         }
+     }
+     /**
+      * Disable one menu item.
+      *
+      * @param id the menu item ID
+      */
+     public final void disableMenuItem(final int id) {
+         for (TMenuItem item: menuItems) {
+             if (item.getId() == id) {
+                 item.setEnabled(false);
+             }
+         }
+     }
+     /**
+      * Disable the range of menu items with ID's between lower and upper,
+      * inclusive.
+      *
+      * @param lower the lowest menu item ID
+      * @param upper the highest menu item ID
+      */
+     public final void disableMenuItems(final int lower, final int upper) {
+         for (TMenuItem item: menuItems) {
+             if ((item.getId() >= lower) && (item.getId() <= upper)) {
+                 item.setEnabled(false);
+                 item.getParent().activate(0);
+             }
+         }
+     }
+     /**
+      * Enable one menu item.
+      *
+      * @param id the menu item ID
+      */
+     public final void enableMenuItem(final int id) {
+         for (TMenuItem item: menuItems) {
+             if (item.getId() == id) {
+                 item.setEnabled(true);
+                 item.getParent().activate(0);
+             }
+         }
+     }
+     /**
+      * Enable the range of menu items with ID's between lower and upper,
+      * inclusive.
+      *
+      * @param lower the lowest menu item ID
+      * @param upper the highest menu item ID
+      */
+     public final void enableMenuItems(final int lower, final int upper) {
+         for (TMenuItem item: menuItems) {
+             if ((item.getId() >= lower) && (item.getId() <= upper)) {
+                 item.setEnabled(true);
+                 item.getParent().activate(0);
+             }
+         }
+     }
+     /**
+      * Get the menu item associated with this ID.
+      *
+      * @param id the menu item ID
+      * @return the menu item, or null if not found
+      */
+     public final TMenuItem getMenuItem(final int id) {
+         for (TMenuItem item: menuItems) {
+             if (item.getId() == id) {
+                 return item;
+             }
+         }
+         return null;
+     }
+     /**
+      * Recompute menu x positions based on their title length.
+      */
+     public final void recomputeMenuX() {
+         int x = 0;
+         for (TMenu menu: menus) {
+             menu.setX(x);
+             menu.setTitleX(x);
+             x += StringUtils.width(menu.getTitle()) + 2;
+             // Don't let the menu window exceed the screen width
+             int rightEdge = menu.getX() + menu.getWidth();
+             if (rightEdge > getScreen().getWidth()) {
+                 menu.setX(getScreen().getWidth() - menu.getWidth());
+             }
+         }
+     }
+     /**
+      * Post an event to process.
+      *
+      * @param event new event to add to the queue
+      */
+     public final void postEvent(final TInputEvent event) {
+         synchronized (this) {
+             synchronized (fillEventQueue) {
+                 fillEventQueue.add(event);
+             }
+             if (debugThreads) {
+                 System.err.println(System.currentTimeMillis() + " " +
+                     Thread.currentThread() + " postEvent() wake up main");
+             }
+             this.notify();
+         }
+     }
+     /**
+      * Post an event to process and turn off the menu.
+      *
+      * @param event new event to add to the queue
+      */
+     public final void postMenuEvent(final TInputEvent event) {
+         synchronized (this) {
+             synchronized (fillEventQueue) {
+                 fillEventQueue.add(event);
+             }
+             if (debugThreads) {
+                 System.err.println(System.currentTimeMillis() + " " +
+                     Thread.currentThread() + " postMenuEvent() wake up main");
+             }
+             closeMenu();
+             this.notify();
+         }
+     }
+     /**
+      * Add a sub-menu to the list of open sub-menus.
+      *
+      * @param menu sub-menu
+      */
+     public final void addSubMenu(final TMenu menu) {
+         subMenus.add(menu);
+     }
+     /**
+      * Convenience function to add a top-level menu.
+      *
+      * @param title menu title
+      * @return the new menu
+      */
+     public final TMenu addMenu(final String title) {
+         int x = 0;
+         int y = 0;
+         TMenu menu = new TMenu(this, x, y, title);
+         menus.add(menu);
+         recomputeMenuX();
+         return menu;
+     }
+     /**
+      * Convenience function to add a default tools (hamburger) menu.
+      *
+      * @return the new menu
+      */
+     public final TMenu addToolMenu() {
+         TMenu toolMenu = addMenu(i18n.getString("toolMenuTitle"));
+         toolMenu.addDefaultItem(TMenu.MID_REPAINT);
+         toolMenu.addDefaultItem(TMenu.MID_VIEW_IMAGE);
+         toolMenu.addDefaultItem(TMenu.MID_SCREEN_OPTIONS);
+         TStatusBar toolStatusBar = toolMenu.newStatusBar(i18n.
+             getString("toolMenuStatus"));
+         toolStatusBar.addShortcutKeypress(kbF1, cmHelp, i18n.getString("Help"));
+         return toolMenu;
+     }
+     /**
+      * Convenience function to add a default "File" menu.
+      *
+      * @return the new menu
+      */
+     public final TMenu addFileMenu() {
+         TMenu fileMenu = addMenu(i18n.getString("fileMenuTitle"));
+         fileMenu.addDefaultItem(TMenu.MID_SHELL);
+         fileMenu.addSeparator();
+         fileMenu.addDefaultItem(TMenu.MID_EXIT);
+         TStatusBar statusBar = fileMenu.newStatusBar(i18n.
+             getString("fileMenuStatus"));
+         statusBar.addShortcutKeypress(kbF1, cmHelp, i18n.getString("Help"));
+         return fileMenu;
+     }
+     /**
+      * Convenience function to add a default "Edit" menu.
+      *
+      * @return the new menu
+      */
+     public final TMenu addEditMenu() {
+         TMenu editMenu = addMenu(i18n.getString("editMenuTitle"));
+         editMenu.addDefaultItem(TMenu.MID_CUT);
+         editMenu.addDefaultItem(TMenu.MID_COPY);
+         editMenu.addDefaultItem(TMenu.MID_PASTE);
+         editMenu.addDefaultItem(TMenu.MID_CLEAR);
+         TStatusBar statusBar = editMenu.newStatusBar(i18n.
+             getString("editMenuStatus"));
+         statusBar.addShortcutKeypress(kbF1, cmHelp, i18n.getString("Help"));
+         return editMenu;
+     }
+     /**
+      * Convenience function to add a default "Window" menu.
+      *
+      * @return the new menu
+      */
+     public final TMenu addWindowMenu() {
+         TMenu windowMenu = addMenu(i18n.getString("windowMenuTitle"));
+         windowMenu.addDefaultItem(TMenu.MID_TILE);
+         windowMenu.addDefaultItem(TMenu.MID_CASCADE);
+         windowMenu.addDefaultItem(TMenu.MID_CLOSE_ALL);
+         windowMenu.addSeparator();
+         windowMenu.addDefaultItem(TMenu.MID_WINDOW_MOVE);
+         windowMenu.addDefaultItem(TMenu.MID_WINDOW_ZOOM);
+         windowMenu.addDefaultItem(TMenu.MID_WINDOW_NEXT);
+         windowMenu.addDefaultItem(TMenu.MID_WINDOW_PREVIOUS);
+         windowMenu.addDefaultItem(TMenu.MID_WINDOW_CLOSE);
+         TStatusBar statusBar = windowMenu.newStatusBar(i18n.
+             getString("windowMenuStatus"));
+         statusBar.addShortcutKeypress(kbF1, cmHelp, i18n.getString("Help"));
+         return windowMenu;
+     }
+     /**
+      * Convenience function to add a default "Help" menu.
+      *
+      * @return the new menu
+      */
+     public final TMenu addHelpMenu() {
+         TMenu helpMenu = addMenu(i18n.getString("helpMenuTitle"));
+         helpMenu.addDefaultItem(TMenu.MID_HELP_CONTENTS);
+         helpMenu.addDefaultItem(TMenu.MID_HELP_INDEX);
+         helpMenu.addDefaultItem(TMenu.MID_HELP_SEARCH);
+         helpMenu.addDefaultItem(TMenu.MID_HELP_PREVIOUS);
+         helpMenu.addDefaultItem(TMenu.MID_HELP_HELP);
+         helpMenu.addDefaultItem(TMenu.MID_HELP_ACTIVE_FILE);
+         helpMenu.addSeparator();
+         helpMenu.addDefaultItem(TMenu.MID_ABOUT);
+         TStatusBar statusBar = helpMenu.newStatusBar(i18n.
+             getString("helpMenuStatus"));
+         statusBar.addShortcutKeypress(kbF1, cmHelp, i18n.getString("Help"));
+         return helpMenu;
+     }
+     /**
+      * Convenience function to add a default "Table" menu.
+      *
+      * @return the new menu
+      */
+     public final TMenu addTableMenu() {
+         TMenu tableMenu = addMenu(i18n.getString("tableMenuTitle"));
+         tableMenu.addDefaultItem(TMenu.MID_TABLE_RENAME_COLUMN, false);
+         tableMenu.addDefaultItem(TMenu.MID_TABLE_RENAME_ROW, false);
+         tableMenu.addSeparator();
+         TSubMenu viewMenu = tableMenu.addSubMenu(i18n.
+             getString("tableSubMenuView"));
+         viewMenu.addDefaultItem(TMenu.MID_TABLE_VIEW_ROW_LABELS, false);
+         viewMenu.addDefaultItem(TMenu.MID_TABLE_VIEW_COLUMN_LABELS, false);
+         viewMenu.addDefaultItem(TMenu.MID_TABLE_VIEW_HIGHLIGHT_ROW, false);
+         viewMenu.addDefaultItem(TMenu.MID_TABLE_VIEW_HIGHLIGHT_COLUMN, false);
+         TSubMenu borderMenu = tableMenu.addSubMenu(i18n.
+             getString("tableSubMenuBorders"));
+         borderMenu.addDefaultItem(TMenu.MID_TABLE_BORDER_NONE, false);
+         borderMenu.addDefaultItem(TMenu.MID_TABLE_BORDER_ALL, false);
+         borderMenu.addDefaultItem(TMenu.MID_TABLE_BORDER_CELL_NONE, false);
+         borderMenu.addDefaultItem(TMenu.MID_TABLE_BORDER_CELL_ALL, false);
+         borderMenu.addDefaultItem(TMenu.MID_TABLE_BORDER_RIGHT, false);
+         borderMenu.addDefaultItem(TMenu.MID_TABLE_BORDER_LEFT, false);
+         borderMenu.addDefaultItem(TMenu.MID_TABLE_BORDER_TOP, false);
+         borderMenu.addDefaultItem(TMenu.MID_TABLE_BORDER_BOTTOM, false);
+         borderMenu.addDefaultItem(TMenu.MID_TABLE_BORDER_DOUBLE_BOTTOM, false);
+         borderMenu.addDefaultItem(TMenu.MID_TABLE_BORDER_THICK_BOTTOM, false);
+         TSubMenu deleteMenu = tableMenu.addSubMenu(i18n.
+             getString("tableSubMenuDelete"));
+         deleteMenu.addDefaultItem(TMenu.MID_TABLE_DELETE_LEFT, false);
+         deleteMenu.addDefaultItem(TMenu.MID_TABLE_DELETE_UP, false);
+         deleteMenu.addDefaultItem(TMenu.MID_TABLE_DELETE_ROW, false);
+         deleteMenu.addDefaultItem(TMenu.MID_TABLE_DELETE_COLUMN, false);
+         TSubMenu insertMenu = tableMenu.addSubMenu(i18n.
+             getString("tableSubMenuInsert"));
+         insertMenu.addDefaultItem(TMenu.MID_TABLE_INSERT_LEFT, false);
+         insertMenu.addDefaultItem(TMenu.MID_TABLE_INSERT_RIGHT, false);
+         insertMenu.addDefaultItem(TMenu.MID_TABLE_INSERT_ABOVE, false);
+         insertMenu.addDefaultItem(TMenu.MID_TABLE_INSERT_BELOW, false);
+         TSubMenu columnMenu = tableMenu.addSubMenu(i18n.
+             getString("tableSubMenuColumn"));
+         columnMenu.addDefaultItem(TMenu.MID_TABLE_COLUMN_NARROW, false);
+         columnMenu.addDefaultItem(TMenu.MID_TABLE_COLUMN_WIDEN, false);
+         TSubMenu fileMenu = tableMenu.addSubMenu(i18n.
+             getString("tableSubMenuFile"));
+         fileMenu.addDefaultItem(TMenu.MID_TABLE_FILE_OPEN_CSV, false);
+         fileMenu.addDefaultItem(TMenu.MID_TABLE_FILE_SAVE_CSV, false);
+         fileMenu.addDefaultItem(TMenu.MID_TABLE_FILE_SAVE_TEXT, false);
+         TStatusBar statusBar = tableMenu.newStatusBar(i18n.
+             getString("tableMenuStatus"));
+         statusBar.addShortcutKeypress(kbF1, cmHelp, i18n.getString("Help"));
+         return tableMenu;
+     }
+     // ------------------------------------------------------------------------
+     // TTimer management ------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Get the amount of time I can sleep before missing a Timer tick.
+      *
+      * @param timeout = initial (maximum) timeout in millis
+      * @return number of milliseconds between now and the next timer event
+      */
+     private long getSleepTime(final long timeout) {
+         Date now = new Date();
+         long nowTime = now.getTime();
+         long sleepTime = timeout;
+         synchronized (timers) {
+             for (TTimer timer: timers) {
+                 long nextTickTime = timer.getNextTick().getTime();
+                 if (nextTickTime < nowTime) {
+                     return 0;
+                 }
+                 long timeDifference = nextTickTime - nowTime;
+                 if (timeDifference < sleepTime) {
+                     sleepTime = timeDifference;
+                 }
+             }
+         }
+         assert (sleepTime >= 0);
+         assert (sleepTime <= timeout);
+         return sleepTime;
+     }
+     /**
+      * Convenience function to add a timer.
+      *
+      * @param duration number of milliseconds to wait between ticks
+      * @param recurring if true, re-schedule this timer after every tick
+      * @param action function to call when button is pressed
+      * @return the timer
+      */
+     public final TTimer addTimer(final long duration, final boolean recurring,
+         final TAction action) {
+         TTimer timer = new TTimer(duration, recurring, action);
+         synchronized (timers) {
+             timers.add(timer);
+         }
+         return timer;
+     }
+     /**
+      * Convenience function to remove a timer.
+      *
+      * @param timer timer to remove
+      */
+     public final void removeTimer(final TTimer timer) {
+         synchronized (timers) {
+             timers.remove(timer);
+         }
+     }
+     // ------------------------------------------------------------------------
+     // Other TWindow constructors ---------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Convenience function to spawn a message box.
+      *
+      * @param title window title, will be centered along the top border
+      * @param caption message to display.  Use embedded newlines to get a
+      * multi-line box.
+      * @return the new message box
+      */
+     public final TMessageBox messageBox(final String title,
+         final String caption) {
+         return new TMessageBox(this, title, caption, TMessageBox.Type.OK);
+     }
+     /**
+      * Convenience function to spawn a message box.
+      *
+      * @param title window title, will be centered along the top border
+      * @param caption message to display.  Use embedded newlines to get a
+      * multi-line box.
+      * @param type one of the TMessageBox.Type constants.  Default is
+      * Type.OK.
+      * @return the new message box
+      */
+     public final TMessageBox messageBox(final String title,
+         final String caption, final TMessageBox.Type type) {
+         return new TMessageBox(this, title, caption, type);
+     }
+     /**
+      * Convenience function to spawn an input box.
+      *
+      * @param title window title, will be centered along the top border
+      * @param caption message to display.  Use embedded newlines to get a
+      * multi-line box.
+      * @return the new input box
+      */
+     public final TInputBox inputBox(final String title, final String caption) {
+         return new TInputBox(this, title, caption);
+     }
+     /**
+      * Convenience function to spawn an input box.
+      *
+      * @param title window title, will be centered along the top border
+      * @param caption message to display.  Use embedded newlines to get a
+      * multi-line box.
+      * @param text initial text to seed the field with
+      * @return the new input box
+      */
+     public final TInputBox inputBox(final String title, final String caption,
+         final String text) {
+         return new TInputBox(this, title, caption, text);
+     }
+     /**
+      * Convenience function to spawn an input box.
+      *
+      * @param title window title, will be centered along the top border
+      * @param caption message to display.  Use embedded newlines to get a
+      * multi-line box.
+      * @param text initial text to seed the field with
+      * @param type one of the Type constants.  Default is Type.OK.
+      * @return the new input box
+      */
+     public final TInputBox inputBox(final String title, final String caption,
+         final String text, final TInputBox.Type type) {
+         return new TInputBox(this, title, caption, text, type);
+     }
+     /**
+      * Convenience function to open a terminal window.
+      *
+      * @param x column relative to parent
+      * @param y row relative to parent
+      * @return the terminal new window
+      */
+     public final TTerminalWindow openTerminal(final int x, final int y) {
+         return openTerminal(x, y, TWindow.RESIZABLE);
+     }
+     /**
+      * Convenience function to open a terminal window.
+      *
+      * @param x column relative to parent
+      * @param y row relative to parent
+      * @param closeOnExit if true, close the window when the command exits
+      * @return the terminal new window
+      */
+     public final TTerminalWindow openTerminal(final int x, final int y,
+         final boolean closeOnExit) {
+         return openTerminal(x, y, TWindow.RESIZABLE, closeOnExit);
+     }
+     /**
+      * Convenience function to open a terminal window.
+      *
+      * @param x column relative to parent
+      * @param y row relative to parent
+      * @param flags mask of CENTERED, MODAL, or RESIZABLE
+      * @return the terminal new window
+      */
+     public final TTerminalWindow openTerminal(final int x, final int y,
+         final int flags) {
+         return new TTerminalWindow(this, x, y, flags);
+     }
+     /**
+      * Convenience function to open a terminal window.
+      *
+      * @param x column relative to parent
+      * @param y row relative to parent
+      * @param flags mask of CENTERED, MODAL, or RESIZABLE
+      * @param closeOnExit if true, close the window when the command exits
+      * @return the terminal new window
+      */
+     public final TTerminalWindow openTerminal(final int x, final int y,
+         final int flags, final boolean closeOnExit) {
+         return new TTerminalWindow(this, x, y, flags, closeOnExit);
+     }
+     /**
+      * Convenience function to open a terminal window and execute a custom
+      * command line inside it.
+      *
+      * @param x column relative to parent
+      * @param y row relative to parent
+      * @param commandLine the command line to execute
+      * @return the terminal new window
+      */
+     public final TTerminalWindow openTerminal(final int x, final int y,
+         final String commandLine) {
+         return openTerminal(x, y, TWindow.RESIZABLE, commandLine);
+     }
+     /**
+      * Convenience function to open a terminal window and execute a custom
+      * command line inside it.
+      *
+      * @param x column relative to parent
+      * @param y row relative to parent
+      * @param commandLine the command line to execute
+      * @param closeOnExit if true, close the window when the command exits
+      * @return the terminal new window
+      */
+     public final TTerminalWindow openTerminal(final int x, final int y,
+         final String commandLine, final boolean closeOnExit) {
+         return openTerminal(x, y, TWindow.RESIZABLE, commandLine, closeOnExit);
+     }
+     /**
+      * Convenience function to open a terminal window and execute a custom
+      * command line inside it.
+      *
+      * @param x column relative to parent
+      * @param y row relative to parent
+      * @param flags mask of CENTERED, MODAL, or RESIZABLE
+      * @param command the command line to execute
+      * @return the terminal new window
+      */
+     public final TTerminalWindow openTerminal(final int x, final int y,
+         final int flags, final String [] command) {
+         return new TTerminalWindow(this, x, y, flags, command);
+     }
+     /**
+      * Convenience function to open a terminal window and execute a custom
+      * command line inside it.
+      *
+      * @param x column relative to parent
+      * @param y row relative to parent
+      * @param flags mask of CENTERED, MODAL, or RESIZABLE
+      * @param command the command line to execute
+      * @param closeOnExit if true, close the window when the command exits
+      * @return the terminal new window
+      */
+     public final TTerminalWindow openTerminal(final int x, final int y,
+         final int flags, final String [] command, final boolean closeOnExit) {
+         return new TTerminalWindow(this, x, y, flags, command, closeOnExit);
+     }
+     /**
+      * Convenience function to open a terminal window and execute a custom
+      * command line inside it.
+      *
+      * @param x column relative to parent
+      * @param y row relative to parent
+      * @param flags mask of CENTERED, MODAL, or RESIZABLE
+      * @param commandLine the command line to execute
+      * @return the terminal new window
+      */
+     public final TTerminalWindow openTerminal(final int x, final int y,
+         final int flags, final String commandLine) {
+         return new TTerminalWindow(this, x, y, flags, commandLine.split("\\s+"));
+     }
+     /**
+      * Convenience function to open a terminal window and execute a custom
+      * command line inside it.
+      *
+      * @param x column relative to parent
+      * @param y row relative to parent
+      * @param flags mask of CENTERED, MODAL, or RESIZABLE
+      * @param commandLine the command line to execute
+      * @param closeOnExit if true, close the window when the command exits
+      * @return the terminal new window
+      */
+     public final TTerminalWindow openTerminal(final int x, final int y,
+         final int flags, final String commandLine, final boolean closeOnExit) {
+         return new TTerminalWindow(this, x, y, flags, commandLine.split("\\s+"),
+             closeOnExit);
+     }
+     /**
+      * Convenience function to spawn an file open box.
+      *
+      * @param path path of selected file
+      * @return the result of the new file open box
+      * @throws IOException if java.io operation throws
+      */
+     public final String fileOpenBox(final String path) throws IOException {
+         TFileOpenBox box = new TFileOpenBox(this, path, TFileOpenBox.Type.OPEN);
+         return box.getFilename();
+     }
+     /**
+      * Convenience function to spawn an file open box.
+      *
+      * @param path path of selected file
+      * @param type one of the Type constants
+      * @return the result of the new file open box
+      * @throws IOException if java.io operation throws
+      */
+     public final String fileOpenBox(final String path,
+         final TFileOpenBox.Type type) throws IOException {
+         TFileOpenBox box = new TFileOpenBox(this, path, type);
+         return box.getFilename();
+     }
+     /**
+      * Convenience function to spawn a file open box.
+      *
+      * @param path path of selected file
+      * @param type one of the Type constants
+      * @param filter a string that files must match to be displayed
+      * @return the result of the new file open box
+      * @throws IOException of a java.io operation throws
+      */
+     public final String fileOpenBox(final String path,
+         final TFileOpenBox.Type type, final String filter) throws IOException {
+         ArrayList<String> filters = new ArrayList<String>();
+         filters.add(filter);
+         TFileOpenBox box = new TFileOpenBox(this, path, type, filters);
+         return box.getFilename();
+     }
+     /**
+      * Convenience function to spawn a file open box.
+      *
+      * @param path path of selected file
+      * @param type one of the Type constants
+      * @param filters a list of strings that files must match to be displayed
+      * @return the result of the new file open box
+      * @throws IOException of a java.io operation throws
+      */
+     public final String fileOpenBox(final String path,
+         final TFileOpenBox.Type type,
+         final List<String> filters) throws IOException {
+         TFileOpenBox box = new TFileOpenBox(this, path, type, filters);
+         return box.getFilename();
+     }
+     /**
+      * Convenience function to create a new window and make it active.
+      * Window will be located at (0, 0).
+      *
+      * @param title window title, will be centered along the top border
+      * @param width width of window
+      * @param height height of window
+      * @return the new window
+      */
+     public final TWindow addWindow(final String title, final int width,
+         final int height) {
+         TWindow window = new TWindow(this, title, 0, 0, width, height);
+         return window;
+     }
+     /**
+      * Convenience function to create a new window and make it active.
+      * Window will be located at (0, 0).
+      *
+      * @param title window title, will be centered along the top border
+      * @param width width of window
+      * @param height height of window
+      * @param flags bitmask of RESIZABLE, CENTERED, or MODAL
+      * @return the new window
+      */
+     public final TWindow addWindow(final String title,
+         final int width, final int height, final int flags) {
+         TWindow window = new TWindow(this, title, 0, 0, width, height, flags);
+         return window;
+     }
+     /**
+      * Convenience function to create a new window and make it active.
+      *
+      * @param title window title, will be centered along the top border
+      * @param x column relative to parent
+      * @param y row relative to parent
+      * @param width width of window
+      * @param height height of window
+      * @return the new window
+      */
+     public final TWindow addWindow(final String title,
+         final int x, final int y, final int width, final int height) {
+         TWindow window = new TWindow(this, title, x, y, width, height);
+         return window;
+     }
+     /**
+      * Convenience function to create a new window and make it active.
+      *
+      * @param title window title, will be centered along the top border
+      * @param x column relative to parent
+      * @param y row relative to parent
+      * @param width width of window
+      * @param height height of window
+      * @param flags mask of RESIZABLE, CENTERED, or MODAL
+      * @return the new window
+      */
+     public final TWindow addWindow(final String title,
+         final int x, final int y, final int width, final int height,
+         final int flags) {
+         TWindow window = new TWindow(this, title, x, y, width, height, flags);
+         return window;
+     }
+ }
index 0000000000000000000000000000000000000000,299c6a3a90501469469dc7a8550a3c8f42cf146d..299c6a3a90501469469dc7a8550a3c8f42cf146d
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,27 +1,27 @@@
+ Help=Help
+ toolMenuTitle=&\u2261
+ toolMenuStatus=Additional tools
+ fileMenuTitle=&File
+ fileMenuStatus=File-management commands (Open, Save, Print, etc.)
+ editMenuTitle=&Edit
+ editMenuStatus=Editor operations, undo, and Clipboard access
+ windowMenuTitle=&Window
+ windowMenuStatus=Open, arrange, and list windows
+ helpMenuTitle=&Help
+ helpMenuStatus=Access online help
+ tableMenuTitle=&Table
+ tableSubMenuView=&View
+ tableSubMenuBorders=&Borders
+ tableSubMenuDelete=&Delete
+ tableSubMenuInsert=&Insert
+ tableSubMenuColumn=&Column
+ tableSubMenuFile=&File
+ tableMenuStatus=Table manipulation commands
+ exitDialogTitle=Confirmation
+ exitDialogText=Exit application?
+ aboutDialogTitle=About
+ aboutDialogText=Jexer Version {0}
diff --combined src/jexer/TButton.java
index 0000000000000000000000000000000000000000,d86fa4400d16b51dc3be15dbaa38f5421ffdc7b5..d86fa4400d16b51dc3be15dbaa38f5421ffdc7b5
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,333 +1,333 @@@
+ /*
+  * Jexer - Java Text User Interface
+  *
+  * The MIT License (MIT)
+  *
+  * 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"),
+  * to deal in the Software without restriction, including without limitation
+  * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+  * and/or sell copies of the Software, and to permit persons to whom the
+  * Software is furnished to do so, subject to the following conditions:
+  *
+  * The above copyright notice and this permission notice shall be included in
+  * all copies or substantial portions of the Software.
+  *
+  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+  * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+  * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+  * DEALINGS IN THE SOFTWARE.
+  *
+  * @author Kevin Lamonte [kevin.lamonte@gmail.com]
+  * @version 1
+  */
+ package jexer;
+ import jexer.bits.CellAttributes;
+ import jexer.bits.Color;
+ import jexer.bits.GraphicsChars;
+ import jexer.bits.MnemonicString;
+ import jexer.bits.StringUtils;
+ import jexer.event.TKeypressEvent;
+ import jexer.event.TMouseEvent;
+ import static jexer.TKeypress.kbEnter;
+ import static jexer.TKeypress.kbSpace;
+ /**
+  * TButton implements a simple button.  To make the button do something, pass
+  * a TAction class to its constructor.
+  *
+  * @see TAction#DO()
+  */
+ public class TButton extends TWidget {
+     // ------------------------------------------------------------------------
+     // Variables --------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * The shortcut and button text.
+      */
+     private MnemonicString mnemonic;
+     /**
+      * Remember mouse state.
+      */
+     private TMouseEvent mouse;
+     /**
+      * True when the button is being pressed and held down.
+      */
+     private boolean inButtonPress = false;
+     /**
+      * The action to perform when the button is clicked.
+      */
+     private TAction action;
+     /**
+      * The background color used for the button "shadow", or null for "no
+      * shadow".
+      */
+     private CellAttributes shadowColor;
+     // ------------------------------------------------------------------------
+     // Constructors -----------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Private constructor.
+      *
+      * @param parent parent widget
+      * @param text label on the button
+      * @param x column relative to parent
+      * @param y row relative to parent
+      */
+     private TButton(final TWidget parent, final String text,
+         final int x, final int y) {
+         // Set parent and window
+         super(parent);
+         mnemonic = new MnemonicString(text);
+         setX(x);
+         setY(y);
+         super.setHeight(2);
+         super.setWidth(StringUtils.width(mnemonic.getRawLabel()) + 3);
+         shadowColor = new CellAttributes();
+         shadowColor.setTo(getWindow().getBackground());
+         shadowColor.setForeColor(Color.BLACK);
+         shadowColor.setBold(false);
+         // Since we set dimensions after TWidget's constructor, we need to
+         // update the layout manager.
+         if (getParent().getLayoutManager() != null) {
+             getParent().getLayoutManager().remove(this);
+             getParent().getLayoutManager().add(this);
+         }
+     }
+     /**
+      * Public constructor.
+      *
+      * @param parent parent widget
+      * @param text label on the button
+      * @param x column relative to parent
+      * @param y row relative to parent
+      * @param action to call when button is pressed
+      */
+     public TButton(final TWidget parent, final String text,
+         final int x, final int y, final TAction action) {
+         this(parent, text, x, y);
+         this.action = action;
+     }
+     // ------------------------------------------------------------------------
+     // Event handlers ---------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Returns true if the mouse is currently on the button.
+      *
+      * @return if true the mouse is currently on the button
+      */
+     private boolean mouseOnButton() {
+         int rightEdge = getWidth() - 1;
+         if (inButtonPress) {
+             rightEdge++;
+         }
+         if ((mouse != null)
+             && (mouse.getY() == 0)
+             && (mouse.getX() >= 0)
+             && (mouse.getX() < rightEdge)
+         ) {
+             return true;
+         }
+         return false;
+     }
+     /**
+      * Handle mouse button presses.
+      *
+      * @param mouse mouse button event
+      */
+     @Override
+     public void onMouseDown(final TMouseEvent mouse) {
+         this.mouse = mouse;
+         if ((mouseOnButton()) && (mouse.isMouse1())) {
+             // Begin button press
+             inButtonPress = true;
+         }
+     }
+     /**
+      * Handle mouse button releases.
+      *
+      * @param mouse mouse button release event
+      */
+     @Override
+     public void onMouseUp(final TMouseEvent mouse) {
+         this.mouse = mouse;
+         if (inButtonPress && mouse.isMouse1()) {
+             // Dispatch the event
+             dispatch();
+         }
+     }
+     /**
+      * Handle mouse movements.
+      *
+      * @param mouse mouse motion event
+      */
+     @Override
+     public void onMouseMotion(final TMouseEvent mouse) {
+         this.mouse = mouse;
+         if (!mouseOnButton()) {
+             inButtonPress = false;
+         }
+     }
+     /**
+      * Handle keystrokes.
+      *
+      * @param keypress keystroke event
+      */
+     @Override
+     public void onKeypress(final TKeypressEvent keypress) {
+         if (keypress.equals(kbEnter)
+             || keypress.equals(kbSpace)
+         ) {
+             // Dispatch
+             dispatch();
+             return;
+         }
+         // Pass to parent for the things we don't care about.
+         super.onKeypress(keypress);
+     }
+     // ------------------------------------------------------------------------
+     // TWidget ----------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Override TWidget's width: we can only set width at construction time.
+      *
+      * @param width new widget width (ignored)
+      */
+     @Override
+     public void setWidth(final int width) {
+         // Do nothing
+     }
+     /**
+      * Override TWidget's height: we can only set height at construction
+      * time.
+      *
+      * @param height new widget height (ignored)
+      */
+     @Override
+     public void setHeight(final int height) {
+         // Do nothing
+     }
+     /**
+      * Draw a button with a shadow.
+      */
+     @Override
+     public void draw() {
+         CellAttributes buttonColor;
+         CellAttributes menuMnemonicColor;
+         if (!isEnabled()) {
+             buttonColor = getTheme().getColor("tbutton.disabled");
+             menuMnemonicColor = getTheme().getColor("tbutton.disabled");
+         } else if (isAbsoluteActive()) {
+             buttonColor = getTheme().getColor("tbutton.active");
+             menuMnemonicColor = getTheme().getColor("tbutton.mnemonic.highlighted");
+         } else {
+             buttonColor = getTheme().getColor("tbutton.inactive");
+             menuMnemonicColor = getTheme().getColor("tbutton.mnemonic");
+         }
+         if (inButtonPress) {
+             putCharXY(1, 0, ' ', buttonColor);
+             putStringXY(2, 0, mnemonic.getRawLabel(), buttonColor);
+             putCharXY(getWidth() - 1, 0, ' ', buttonColor);
+         } else {
+             putCharXY(0, 0, ' ', buttonColor);
+             putStringXY(1, 0, mnemonic.getRawLabel(), buttonColor);
+             putCharXY(getWidth() - 2, 0, ' ', buttonColor);
+             if (shadowColor != null) {
+                 putCharXY(getWidth() - 1, 0,
+                     GraphicsChars.CP437[0xDC], shadowColor);
+                 hLineXY(1, 1, getWidth() - 1,
+                     GraphicsChars.CP437[0xDF], shadowColor);
+             }
+         }
+         if (mnemonic.getScreenShortcutIdx() >= 0) {
+             if (inButtonPress) {
+                 putCharXY(2 + mnemonic.getScreenShortcutIdx(), 0,
+                     mnemonic.getShortcut(), menuMnemonicColor);
+             } else {
+                 putCharXY(1 + mnemonic.getScreenShortcutIdx(), 0,
+                     mnemonic.getShortcut(), menuMnemonicColor);
+             }
+         }
+     }
+     // ------------------------------------------------------------------------
+     // TButton ----------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Get the mnemonic string for this button.
+      *
+      * @return mnemonic string
+      */
+     public MnemonicString getMnemonic() {
+         return mnemonic;
+     }
+     /**
+      * Act as though the button was pressed.  This is useful for other UI
+      * elements to get the same action as if the user clicked the button.
+      */
+     public void dispatch() {
+         if (action != null) {
+             action.DO(this);
+             inButtonPress = false;
+         }
+     }
+     /**
+      * Set the background color used for the button "shadow".  If null, no
+      * shadow will be drawn.
+      *
+      * @param color the new background color, or null for no shadow
+      */
+     public void setShadowColor(final CellAttributes color) {
+         if (color != null) {
+             shadowColor = new CellAttributes();
+             shadowColor.setTo(color);
+             shadowColor.setForeColor(Color.BLACK);
+             shadowColor.setBold(false);
+         } else {
+             shadowColor = null;
+         }
+     }
+ }
diff --combined src/jexer/TCalendar.java
index 0000000000000000000000000000000000000000,c2005ccb60634279c8a3e5eb064cb59dc083ea04..c2005ccb60634279c8a3e5eb064cb59dc083ea04
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,324 +1,324 @@@
+ /*
+  * Jexer - Java Text User Interface
+  *
+  * The MIT License (MIT)
+  *
+  * 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"),
+  * to deal in the Software without restriction, including without limitation
+  * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+  * and/or sell copies of the Software, and to permit persons to whom the
+  * Software is furnished to do so, subject to the following conditions:
+  *
+  * The above copyright notice and this permission notice shall be included in
+  * all copies or substantial portions of the Software.
+  *
+  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+  * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+  * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+  * DEALINGS IN THE SOFTWARE.
+  *
+  * @author Kevin Lamonte [kevin.lamonte@gmail.com]
+  * @version 1
+  */
+ package jexer;
+ import java.util.Calendar;
+ import java.util.GregorianCalendar;
+ import jexer.bits.CellAttributes;
+ import jexer.bits.GraphicsChars;
+ import jexer.bits.StringUtils;
+ import jexer.event.TKeypressEvent;
+ import jexer.event.TMouseEvent;
+ import static jexer.TKeypress.*;
+ /**
+  * TCalendar is a date picker widget.
+  */
+ public class TCalendar extends TWidget {
+     // ------------------------------------------------------------------------
+     // Variables --------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * The calendar being displayed.
+      */
+     private GregorianCalendar displayCalendar = new GregorianCalendar();
+     /**
+      * The calendar with the selected day.
+      */
+     private GregorianCalendar calendar = new GregorianCalendar();
+     /**
+      * The action to perform when the user changes the value of the calendar.
+      */
+     private TAction updateAction = null;
+     // ------------------------------------------------------------------------
+     // Constructors -----------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Public constructor.
+      *
+      * @param parent parent widget
+      * @param x column relative to parent
+      * @param y row relative to parent
+      * @param updateAction action to call when the user changes the value of
+      * the calendar
+      */
+     public TCalendar(final TWidget parent, final int x, final int y,
+         final TAction updateAction) {
+         // Set parent and window
+         super(parent, x, y, 28, 8);
+         this.updateAction = updateAction;
+     }
+     // ------------------------------------------------------------------------
+     // Event handlers ---------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Returns true if the mouse is currently on the left arrow.
+      *
+      * @param mouse mouse event
+      * @return true if the mouse is currently on the left arrow
+      */
+     private boolean mouseOnLeftArrow(final TMouseEvent mouse) {
+         if ((mouse.getY() == 0)
+             && (mouse.getX() == 1)
+         ) {
+             return true;
+         }
+         return false;
+     }
+     /**
+      * Returns true if the mouse is currently on the right arrow.
+      *
+      * @param mouse mouse event
+      * @return true if the mouse is currently on the right arrow
+      */
+     private boolean mouseOnRightArrow(final TMouseEvent mouse) {
+         if ((mouse.getY() == 0)
+             && (mouse.getX() == getWidth() - 2)
+         ) {
+             return true;
+         }
+         return false;
+     }
+     /**
+      * Handle mouse down clicks.
+      *
+      * @param mouse mouse button down event
+      */
+     @Override
+     public void onMouseDown(final TMouseEvent mouse) {
+         if ((mouseOnLeftArrow(mouse)) && (mouse.isMouse1())) {
+             displayCalendar.add(Calendar.MONTH, -1);
+         } else if ((mouseOnRightArrow(mouse)) && (mouse.isMouse1())) {
+             displayCalendar.add(Calendar.MONTH, 1);
+         } else if (mouse.isMouse1()) {
+             // Find the day this might correspond to, and set it.
+             int index = (mouse.getY() - 2) * 7 + (mouse.getX() / 4) + 1;
+             // System.err.println("index: " + index);
+             int lastDayNumber = displayCalendar.getActualMaximum(
+                     Calendar.DAY_OF_MONTH);
+             GregorianCalendar firstOfMonth = new GregorianCalendar();
+             firstOfMonth.setTimeInMillis(displayCalendar.getTimeInMillis());
+             firstOfMonth.set(Calendar.DAY_OF_MONTH, 1);
+             int dayOf1st = firstOfMonth.get(Calendar.DAY_OF_WEEK) - 1;
+             // System.err.println("dayOf1st: " + dayOf1st);
+             int day = index - dayOf1st;
+             // System.err.println("day: " + day);
+             if ((day < 1) || (day > lastDayNumber)) {
+                 return;
+             }
+             calendar.setTimeInMillis(displayCalendar.getTimeInMillis());
+             calendar.set(Calendar.DAY_OF_MONTH, day);
+         }
+     }
+     /**
+      * Handle mouse double click.
+      *
+      * @param mouse mouse double click event
+      */
+     @Override
+     public void onMouseDoubleClick(final TMouseEvent mouse) {
+         if (updateAction != null) {
+             updateAction.DO(this);
+         }
+     }
+     /**
+      * Handle keystrokes.
+      *
+      * @param keypress keystroke event
+      */
+     @Override
+     public void onKeypress(final TKeypressEvent keypress) {
+         int increment = 0;
+         if (keypress.equals(kbUp)) {
+             increment = -7;
+         } else if (keypress.equals(kbDown)) {
+             increment = 7;
+         } else if (keypress.equals(kbLeft)) {
+             increment = -1;
+         } else if (keypress.equals(kbRight)) {
+             increment = 1;
+         } else if (keypress.equals(kbEnter)) {
+             if (updateAction != null) {
+                 updateAction.DO(this);
+             }
+             return;
+         } else {
+             // Pass to parent for the things we don't care about.
+             super.onKeypress(keypress);
+             return;
+         }
+         if (increment != 0) {
+             calendar.add(Calendar.DAY_OF_YEAR, increment);
+             if ((displayCalendar.get(Calendar.MONTH) != calendar.get(
+                     Calendar.MONTH))
+                 || (displayCalendar.get(Calendar.YEAR) != calendar.get(
+                     Calendar.YEAR))
+             ) {
+                 if (increment < 0) {
+                     displayCalendar.add(Calendar.MONTH, -1);
+                 } else {
+                     displayCalendar.add(Calendar.MONTH, 1);
+                 }
+             }
+         }
+     }
+     // ------------------------------------------------------------------------
+     // TWidget ----------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Draw the combobox down arrow.
+      */
+     @Override
+     public void draw() {
+         CellAttributes backgroundColor = getTheme().getColor(
+                 "tcalendar.background");
+         CellAttributes dayColor = getTheme().getColor(
+                 "tcalendar.day");
+         CellAttributes selectedDayColor = getTheme().getColor(
+                 "tcalendar.day.selected");
+         CellAttributes arrowColor = getTheme().getColor(
+                 "tcalendar.arrow");
+         CellAttributes titleColor = getTheme().getColor(
+                 "tcalendar.title");
+         // Fill in the interior background
+         for (int i = 0; i < getHeight(); i++) {
+             hLineXY(0, i, getWidth(), ' ', backgroundColor);
+         }
+         // Draw the title
+         String title = String.format("%tB %tY", displayCalendar,
+             displayCalendar);
+         // This particular title is always single-width (see format string
+         // above), but for completeness let's treat it the same as every
+         // other window title string.
+         int titleLeft = (getWidth() - StringUtils.width(title) - 2) / 2;
+         putCharXY(titleLeft, 0, ' ', titleColor);
+         putStringXY(titleLeft + 1, 0, title, titleColor);
+         putCharXY(titleLeft + StringUtils.width(title) + 1, 0, ' ',
+             titleColor);
+         // Arrows
+         putCharXY(1, 0, GraphicsChars.LEFTARROW, arrowColor);
+         putCharXY(getWidth() - 2, 0, GraphicsChars.RIGHTARROW,
+             arrowColor);
+         /*
+          * Now draw out the days.
+          */
+         putStringXY(0, 1, "  S   M   T   W   T   F   S ", dayColor);
+         int lastDayNumber = displayCalendar.getActualMaximum(
+                 Calendar.DAY_OF_MONTH);
+         GregorianCalendar firstOfMonth = new GregorianCalendar();
+         firstOfMonth.setTimeInMillis(displayCalendar.getTimeInMillis());
+         firstOfMonth.set(Calendar.DAY_OF_MONTH, 1);
+         int dayOf1st = firstOfMonth.get(Calendar.DAY_OF_WEEK) - 1;
+         int dayColumn = dayOf1st * 4;
+         int row = 2;
+         int dayOfMonth = 1;
+         while (dayOfMonth <= lastDayNumber) {
+             if (dayColumn == 4 * 7) {
+                 dayColumn = 0;
+                 row++;
+             }
+             if ((dayOfMonth == calendar.get(Calendar.DAY_OF_MONTH))
+                 && (displayCalendar.get(Calendar.MONTH) == calendar.get(
+                     Calendar.MONTH))
+                 && (displayCalendar.get(Calendar.YEAR) == calendar.get(
+                     Calendar.YEAR))
+             ) {
+                 putStringXY(dayColumn, row,
+                     String.format(" %2d ", dayOfMonth), selectedDayColor);
+             } else {
+                 putStringXY(dayColumn, row,
+                     String.format(" %2d ", dayOfMonth), dayColor);
+             }
+             dayColumn += 4;
+             dayOfMonth++;
+         }
+     }
+     // ------------------------------------------------------------------------
+     // TCalendar --------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Get calendar value.
+      *
+      * @return the current calendar value (clone instance)
+      */
+     public Calendar getValue() {
+         return (Calendar) calendar.clone();
+     }
+     /**
+      * Set calendar value.
+      *
+      * @param calendar the new value to use
+      */
+     public final void setValue(final Calendar calendar) {
+         this.calendar.setTimeInMillis(calendar.getTimeInMillis());
+     }
+     /**
+      * Set calendar value.
+      *
+      * @param millis the millis to set to
+      */
+     public final void setValue(final long millis) {
+         this.calendar.setTimeInMillis(millis);
+     }
+ }
diff --combined src/jexer/TCheckBox.java
index 0000000000000000000000000000000000000000,1f9a351c0c0c636c538272d372c5a3b4ef43f583..1f9a351c0c0c636c538272d372c5a3b4ef43f583
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,216 +1,216 @@@
+ /*
+  * Jexer - Java Text User Interface
+  *
+  * The MIT License (MIT)
+  *
+  * 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"),
+  * to deal in the Software without restriction, including without limitation
+  * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+  * and/or sell copies of the Software, and to permit persons to whom the
+  * Software is furnished to do so, subject to the following conditions:
+  *
+  * The above copyright notice and this permission notice shall be included in
+  * all copies or substantial portions of the Software.
+  *
+  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+  * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+  * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+  * DEALINGS IN THE SOFTWARE.
+  *
+  * @author Kevin Lamonte [kevin.lamonte@gmail.com]
+  * @version 1
+  */
+ package jexer;
+ import static jexer.TKeypress.kbEnter;
+ import static jexer.TKeypress.kbEsc;
+ import static jexer.TKeypress.kbSpace;
+ import jexer.bits.CellAttributes;
+ import jexer.bits.GraphicsChars;
+ import jexer.bits.MnemonicString;
+ import jexer.bits.StringUtils;
+ import jexer.event.TKeypressEvent;
+ import jexer.event.TMouseEvent;
+ /**
+  * TCheckBox implements an on/off checkbox.
+  */
+ public class TCheckBox extends TWidget {
+     // ------------------------------------------------------------------------
+     // Variables --------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * CheckBox state, true means checked.
+      */
+     private boolean checked = false;
+     /**
+      * The shortcut and checkbox label.
+      */
+     private MnemonicString mnemonic;
+     /**
+      * If true, use the window's background color.
+      */
+     private boolean useWindowBackground = false;
+     // ------------------------------------------------------------------------
+     // Constructors -----------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Public constructor.
+      *
+      * @param parent parent widget
+      * @param x column relative to parent
+      * @param y row relative to parent
+      * @param label label to display next to (right of) the checkbox
+      * @param checked initial check state
+      */
+     public TCheckBox(final TWidget parent, final int x, final int y,
+         final String label, final boolean checked) {
+         // Set parent and window
+         super(parent, x, y, StringUtils.width(label) + 4, 1);
+         mnemonic = new MnemonicString(label);
+         this.checked = checked;
+         setCursorVisible(true);
+         setCursorX(1);
+     }
+     // ------------------------------------------------------------------------
+     // Event handlers ---------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Returns true if the mouse is currently on the checkbox.
+      *
+      * @param mouse mouse event
+      * @return true if the mouse is currently on the checkbox
+      */
+     private boolean mouseOnCheckBox(final TMouseEvent mouse) {
+         if ((mouse.getY() == 0)
+             && (mouse.getX() >= 0)
+             && (mouse.getX() <= 2)
+         ) {
+             return true;
+         }
+         return false;
+     }
+     /**
+      * Handle mouse checkbox presses.
+      *
+      * @param mouse mouse button down event
+      */
+     @Override
+     public void onMouseDown(final TMouseEvent mouse) {
+         if ((mouseOnCheckBox(mouse)) && (mouse.isMouse1())) {
+             // Switch state
+             checked = !checked;
+         }
+     }
+     /**
+      * Handle keystrokes.
+      *
+      * @param keypress keystroke event
+      */
+     @Override
+     public void onKeypress(final TKeypressEvent keypress) {
+         if (keypress.equals(kbSpace)
+             || keypress.equals(kbEnter)
+         ) {
+             checked = !checked;
+             return;
+         }
+         if (keypress.equals(kbEsc)) {
+             checked = false;
+             return;
+         }
+         // Pass to parent for the things we don't care about.
+         super.onKeypress(keypress);
+     }
+     // ------------------------------------------------------------------------
+     // TWidget ----------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Draw a checkbox with label.
+      */
+     @Override
+     public void draw() {
+         CellAttributes checkboxColor;
+         CellAttributes mnemonicColor;
+         if (isAbsoluteActive()) {
+             checkboxColor = getTheme().getColor("tcheckbox.active");
+             mnemonicColor = getTheme().getColor("tcheckbox.mnemonic.highlighted");
+         } else {
+             checkboxColor = getTheme().getColor("tcheckbox.inactive");
+             mnemonicColor = getTheme().getColor("tcheckbox.mnemonic");
+         }
+         if (useWindowBackground) {
+             CellAttributes background = getWindow().getBackground();
+             checkboxColor.setBackColor(background.getBackColor());
+         }
+         putCharXY(0, 0, '[', checkboxColor);
+         if (checked) {
+             putCharXY(1, 0, GraphicsChars.CHECK, checkboxColor);
+         } else {
+             putCharXY(1, 0, ' ', checkboxColor);
+         }
+         putCharXY(2, 0, ']', checkboxColor);
+         putStringXY(4, 0, mnemonic.getRawLabel(), checkboxColor);
+         if (mnemonic.getScreenShortcutIdx() >= 0) {
+             putCharXY(4 + mnemonic.getScreenShortcutIdx(), 0,
+                 mnemonic.getShortcut(), mnemonicColor);
+         }
+     }
+     // ------------------------------------------------------------------------
+     // TCheckBox --------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Get checked value.
+      *
+      * @return if true, this is checked
+      */
+     public boolean isChecked() {
+         return checked;
+     }
+     /**
+      * Set checked value.
+      *
+      * @param checked new checked value.
+      */
+     public void setChecked(final boolean checked) {
+         this.checked = checked;
+     }
+     /**
+      * Get the mnemonic string for this checkbox.
+      *
+      * @return mnemonic string
+      */
+     public MnemonicString getMnemonic() {
+         return mnemonic;
+     }
+ }
diff --combined src/jexer/TComboBox.java
index 0000000000000000000000000000000000000000,1164e6c53f9699e224611f304391e22ee966471b..1164e6c53f9699e224611f304391e22ee966471b
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,465 +1,465 @@@
+ /*
+  * Jexer - Java Text User Interface
+  *
+  * The MIT License (MIT)
+  *
+  * 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"),
+  * to deal in the Software without restriction, including without limitation
+  * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+  * and/or sell copies of the Software, and to permit persons to whom the
+  * Software is furnished to do so, subject to the following conditions:
+  *
+  * The above copyright notice and this permission notice shall be included in
+  * all copies or substantial portions of the Software.
+  *
+  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+  * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+  * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+  * DEALINGS IN THE SOFTWARE.
+  *
+  * @author Kevin Lamonte [kevin.lamonte@gmail.com]
+  * @version 1
+  */
+ package jexer;
+ import java.util.ArrayList;
+ import java.util.List;
+ import jexer.bits.CellAttributes;
+ import jexer.bits.GraphicsChars;
+ import jexer.event.TKeypressEvent;
+ import jexer.event.TMouseEvent;
+ import jexer.event.TResizeEvent;
+ import jexer.event.TResizeEvent.Type;
+ import static jexer.TKeypress.*;
+ /**
+  * TComboBox implements a combobox containing a drop-down list and edit
+  * field.  Alt-Down can be used to show the drop-down.
+  */
+ public class TComboBox extends TWidget {
+     // ------------------------------------------------------------------------
+     // Variables --------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * The list of items in the drop-down.
+      */
+     private TList list;
+     /**
+      * The edit field containing the value to return.
+      */
+     private TField field;
+     /**
+      * The action to perform when the user selects an item (clicks or enter).
+      */
+     private TAction updateAction = null;
+     /**
+      * If true, the field cannot be updated to a value not on the list.
+      */
+     private boolean limitToListValue = true;
+     
+     /**
+      * The height of the list of values when it is shown, or -1 to use the 
+      * number of values in the list as the height.
+      */
+     private int valuesHeight = -1;
+     
+     /**
+      * The values shown by the drop-down list.
+      */
+     private List<String> values = new ArrayList<String>();
+     
+     /**
+      * When looking for a link between the displayed text and the list 
+      * of values, do a case sensitive search.
+      */
+     private boolean caseSensitive = true;
+     /**
+      * The maximum height of the values drop-down when it is visible.
+      */
+     private int maxValuesHeight = 3;
+     // ------------------------------------------------------------------------
+     // Constructors -----------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Public constructor.
+      *
+      * @param parent parent widget
+      * @param x column relative to parent
+      * @param y row relative to parent
+      * @param width visible combobox width, including the down-arrow
+      * @param values the possible values for the box, shown in the drop-down
+      * @param valuesIndex the initial index in values, or -1 for no default
+      * value
+      * @param valuesHeight the height of the values drop-down when it is
+      * visible, or -1 to use the number of values as the height of the list
+      * @param updateAction action to call when a new value is selected from
+      * the list or enter is pressed in the edit field
+      */
+     public TComboBox(final TWidget parent, final int x, final int y,
+         final int width, final List<String> values, final int valuesIndex,
+         final int valuesHeight, final TAction updateAction) {
+         // Set parent and window
+         super(parent, x, y, width, 1);
+         assert (values != null);
+         this.updateAction = updateAction;
+         this.values = values;
+         this.valuesHeight = valuesHeight;
+         field = new TField(this, 0, 0, Math.max(0, width - 3), false, "",
+             updateAction, null);
+         if (valuesIndex >= 0) {
+             field.setText(values.get(valuesIndex));
+         }
+         setHeight(1);
+         if (limitToListValue) {
+             field.setEnabled(false);
+         } else {
+             activate(field);
+         }
+     }
+     // ------------------------------------------------------------------------
+     // Event handlers ---------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Returns true if the mouse is currently on the down arrow.
+      *
+      * @param mouse mouse event
+      * @return true if the mouse is currently on the down arrow
+      */
+     private boolean mouseOnArrow(final TMouseEvent mouse) {
+         if ((mouse.getY() == 0)
+             && (mouse.getX() >= getWidth() - 3)
+             && (mouse.getX() <= getWidth() - 1)
+         ) {
+             return true;
+         }
+         return false;
+     }
+     /**
+      * Handle mouse down clicks.
+      *
+      * @param mouse mouse button down event
+      */
+     @Override
+     public void onMouseDown(final TMouseEvent mouse) {
+         if ((mouseOnArrow(mouse)) && (mouse.isMouse1())) {
+             // Make the list visible or not.
+             if (list != null) {
+                 hideDropdown();
+             } else {
+                 displayDropdown();
+             }
+         }
+         // Pass to parent for the things we don't care about.
+         super.onMouseDown(mouse);
+     }
+     /**
+      * Handle keystrokes.
+      *
+      * @param keypress keystroke event
+      */
+     @Override
+     public void onKeypress(final TKeypressEvent keypress) {
+         if (keypress.equals(kbEsc)) {
+             if (list != null) {
+                 hideDropdown();
+                 return;
+             }
+         }
+         if (keypress.equals(kbAltDown)) {
+             displayDropdown();
+             return;
+         }
+         if (keypress.equals(kbTab)
+             || (keypress.equals(kbShiftTab))
+             || (keypress.equals(kbBackTab))
+         ) {
+             if (list != null) {
+                 hideDropdown();
+                 return;
+             }
+         }
+         // Pass to parent for the things we don't care about.
+         super.onKeypress(keypress);
+     }
+     // ------------------------------------------------------------------------
+     // TWidget ----------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Override TWidget's width: we need to set child widget widths.
+      *
+      * @param width new widget width
+      */
+     @Override
+     public void setWidth(final int width) {
+         if (field != null) {
+             field.setWidth(width - 3);
+         }
+         if (list != null) {
+             list.setWidth(width);
+         }
+         super.setWidth(width);
+     }
+     /**
+      * Override TWidget's height: we can only set height at construction
+      * time.
+      *
+      * @param height new widget height (ignored)
+      */
+     @Override
+     public void setHeight(final int height) {
+         // Do nothing
+     }
+     /**
+      * Draw the combobox down arrow.
+      */
+     @Override
+     public void draw() {
+         CellAttributes comboBoxColor;
+         if (!isAbsoluteActive()) {
+             // We lost focus, turn off the list.
+             hideDropdown();
+         }
+         if (isAbsoluteActive()) {
+             comboBoxColor = getTheme().getColor("tcombobox.active");
+         } else {
+             comboBoxColor = getTheme().getColor("tcombobox.inactive");
+         }
+         putCharXY(getWidth() - 3, 0, GraphicsChars.DOWNARROWLEFT,
+             comboBoxColor);
+         putCharXY(getWidth() - 2, 0, GraphicsChars.DOWNARROW,
+             comboBoxColor);
+         putCharXY(getWidth() - 1, 0, GraphicsChars.DOWNARROWRIGHT,
+             comboBoxColor);
+     }
+     // ------------------------------------------------------------------------
+     // TComboBox --------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Hide the drop-down list.
+      */
+     public void hideList() {
+         list.setEnabled(false);
+         list.setVisible(false);
+         super.setHeight(1);
+         if (limitToListValue == false) {
+             activate(field);
+         }
+     }
+     /**
+      * Show the drop-down list.
+      */
+     public void showList() {
+         list.setEnabled(true);
+         list.setVisible(true);
+         super.setHeight(list.getHeight() + 1);
+         activate(list);
+     }
+     /**
+      * Get combobox text value.
+      *
+      * @return text in the edit field
+      */
+     public String getText() {
+         return field.getText();
+     }
+     /**
+      * Set combobox text value.
+      *
+      * @param text the new text in the edit field
+      */
+     public void setText(final String text) {
+         setText(text, true);
+     }
+     /**
+      * Set combobox text value.
+      *
+      * @param text the new text in the edit field
+      * @param caseSensitive if true, perform a case-sensitive search for the
+      * list item
+      */
+     public void setText(final String text, final boolean caseSensitive) {
+         this.caseSensitive = caseSensitive;
+       field.setText(text);
+         if (list != null) {
+               displayDropdown();
+         }
+     }
+     /**
+      * Set combobox text to one of the list values.
+      *
+      * @param index the index in the list
+      */
+     public void setIndex(final int index) {
+         list.setSelectedIndex(index);
+         field.setText(list.getSelected());
+     }
+     /**
+      * Get a copy of the list of strings to display.
+      *
+      * @return the list of strings
+      */
+     public final List<String> getList() {
+         return list.getList();
+     }
+     /**
+      * Set the new list of strings to display.
+      *
+      * @param list new list of strings
+      */
+     public final void setList(final List<String> list) {
+         this.list.setList(list);
+         this.list.setHeight(Math.max(3, Math.min(list.size() + 1,
+                     maxValuesHeight)));
+         field.setText("");
+     }
+     
+     /**
+      * Make sure the widget displays all its elements correctly according to
+      * the current size and content.
+      */
+     public void reflowData() {
+       // TODO: why setW/setH/reflow not enough for the scrollbars?
+       TList list = this.list;
+       if (list != null) {
+               int valuesHeight = this.valuesHeight;
+               if (valuesHeight < 0) {
+                       valuesHeight = values == null ? 0 : values.size() + 1;
+               }
+               
+               list.onResize(new TResizeEvent(Type.WIDGET, getWidth(), 
+                               valuesHeight));
+               setHeight(valuesHeight + 1);
+       }
+       
+       field.onResize(new TResizeEvent(Type.WIDGET, getWidth(), 
+                       field.getHeight()));
+     }
+     
+     @Override
+     public void onResize(TResizeEvent resize) {
+       super.onResize(resize);
+       reflowData();
+     }
+     /**
+      * Display the drop-down menu represented by {@link TComboBox#list}.
+      */
+     private void displayDropdown() {
+       if (this.list != null) {
+               hideDropdown();
+       }
+       
+       int valuesHeight = this.valuesHeight;
+       if (valuesHeight < 0) {
+               valuesHeight = values == null ? 0 : values.size() + 1;
+       }
+       
+       TList list = new TList(this, values, 0, 1, getWidth(), valuesHeight,
+                       new TAction() {
+                                       @Override
+                                       public void DO() {
+                                               TList list = TComboBox.this.list;
+                                               if (list == null) {
+                                                       return;
+                                               }
+                                               
+                                               field.setText(list.getSelected());
+                                               hideDropdown();
+                                               
+                                               if (updateAction != null) {
+                                                       updateAction.DO();
+                                               }
+                                       }
+                               }
+       );
+       
+       int i = -1;
+       if (values != null) {
+               String current = field.getText();
+               for (i = 0 ; i < values.size() ; i++) {
+                       String value = values.get(i);
+                       if ((caseSensitive && current.equals(value)) 
+                                       || (!caseSensitive && current.equalsIgnoreCase(value))) {
+                               break;
+                       }
+               }
+               
+               if (i >= values.size()) {
+                       i = -1;
+               }
+       }
+       list.setSelectedIndex(i);
+       
+       list.setEnabled(true);
+       list.setVisible(true);
+       
+       this.list = list;
+       
+       reflowData();
+       activate(list);
+     }
+     
+     /**
+      * Hide the drop-down menu represented by {@link TComboBox#list}.
+      */
+     private void hideDropdown() {
+       TList list = this.list;
+       
+       if (list != null) {
+               list.setEnabled(false);
+               list.setVisible(false);
+               removeChild(list);
+               
+               setHeight(1);
+               if (limitToListValue == false) {
+                 activate(field);
+             }
+               
+               this.list = null;
+       }
+     }
+ }
diff --combined src/jexer/TCommand.java
index 0000000000000000000000000000000000000000,874a29dda7fcf2ceae1d92c622d034fb31fb78e9..874a29dda7fcf2ceae1d92c622d034fb31fb78e9
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,227 +1,227 @@@
+ /*
+  * Jexer - Java Text User Interface
+  *
+  * The MIT License (MIT)
+  *
+  * 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"),
+  * to deal in the Software without restriction, including without limitation
+  * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+  * and/or sell copies of the Software, and to permit persons to whom the
+  * Software is furnished to do so, subject to the following conditions:
+  *
+  * The above copyright notice and this permission notice shall be included in
+  * all copies or substantial portions of the Software.
+  *
+  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+  * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+  * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+  * DEALINGS IN THE SOFTWARE.
+  *
+  * @author Kevin Lamonte [kevin.lamonte@gmail.com]
+  * @version 1
+  */
+ package jexer;
+ /**
+  * This class encapsulates a user command event.  User commands can be
+  * generated by menu actions, keyboard accelerators, and other UI elements.
+  * Commands can operate on both the application and individual widgets.
+  */
+ public class TCommand {
+     // ------------------------------------------------------------------------
+     // Constants --------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Immediately abort the application (e.g. remote side closed
+      * connection).
+      */
+     public static final int ABORT               = 1;
+     /**
+      * File open dialog.
+      */
+     public static final int OPEN                = 2;
+     /**
+      * Exit application.
+      */
+     public static final int EXIT                = 3;
+     /**
+      * Spawn OS shell window.
+      */
+     public static final int SHELL               = 4;
+     /**
+      * Cut selected text and copy to the clipboard.
+      */
+     public static final int CUT                 = 5;
+     /**
+      * Copy selected text to clipboard.
+      */
+     public static final int COPY                = 6;
+     /**
+      * Paste from clipboard.
+      */
+     public static final int PASTE               = 7;
+     /**
+      * Clear selected text without copying it to the clipboard.
+      */
+     public static final int CLEAR               = 8;
+     /**
+      * Tile windows.
+      */
+     public static final int TILE                = 9;
+     /**
+      * Cascade windows.
+      */
+     public static final int CASCADE             = 10;
+     /**
+      * Close all windows.
+      */
+     public static final int CLOSE_ALL           = 11;
+     /**
+      * Move (move/resize) window.
+      */
+     public static final int WINDOW_MOVE         = 12;
+     /**
+      * Zoom (maximize/restore) window.
+      */
+     public static final int WINDOW_ZOOM         = 13;
+     /**
+      * Next window (like Alt-TAB).
+      */
+     public static final int WINDOW_NEXT         = 14;
+     /**
+      * Previous window (like Shift-Alt-TAB).
+      */
+     public static final int WINDOW_PREVIOUS     = 15;
+     /**
+      * Close window.
+      */
+     public static final int WINDOW_CLOSE        = 16;
+     /**
+      * Enter help system.
+      */
+     public static final int HELP                = 20;
+     /**
+      * Enter first menu.
+      */
+     public static final int MENU                = 21;
+     /**
+      * Save file.
+      */
+     public static final int SAVE                = 30;
+     /**
+      * Backend disconnected.
+      */
+     public static final int BACKEND_DISCONNECT  = 100;
+     public static final TCommand cmAbort        = new TCommand(ABORT);
+     public static final TCommand cmExit         = new TCommand(EXIT);
+     public static final TCommand cmQuit         = new TCommand(EXIT);
+     public static final TCommand cmOpen         = new TCommand(OPEN);
+     public static final TCommand cmShell        = new TCommand(SHELL);
+     public static final TCommand cmCut          = new TCommand(CUT);
+     public static final TCommand cmCopy         = new TCommand(COPY);
+     public static final TCommand cmPaste        = new TCommand(PASTE);
+     public static final TCommand cmClear        = new TCommand(CLEAR);
+     public static final TCommand cmTile         = new TCommand(TILE);
+     public static final TCommand cmCascade      = new TCommand(CASCADE);
+     public static final TCommand cmCloseAll     = new TCommand(CLOSE_ALL);
+     public static final TCommand cmWindowMove   = new TCommand(WINDOW_MOVE);
+     public static final TCommand cmWindowZoom   = new TCommand(WINDOW_ZOOM);
+     public static final TCommand cmWindowNext   = new TCommand(WINDOW_NEXT);
+     public static final TCommand cmWindowPrevious = new TCommand(WINDOW_PREVIOUS);
+     public static final TCommand cmWindowClose  = new TCommand(WINDOW_CLOSE);
+     public static final TCommand cmHelp         = new TCommand(HELP);
+     public static final TCommand cmSave         = new TCommand(SAVE);
+     public static final TCommand cmMenu         = new TCommand(MENU);
+     public static final TCommand cmBackendDisconnect    = new TCommand(BACKEND_DISCONNECT);
+     // ------------------------------------------------------------------------
+     // Variables --------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Type of command, one of EXIT, CASCADE, etc.
+      */
+     private int type;
+     // ------------------------------------------------------------------------
+     // Constructors -----------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Public constructor.
+      *
+      * @param type the Type of command, one of EXIT, CASCADE, etc.
+      */
+     public TCommand(final int type) {
+         this.type = type;
+     }
+     // ------------------------------------------------------------------------
+     // TCommand ---------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Make human-readable description of this TCommand.
+      *
+      * @return displayable String
+      */
+     @Override
+     public final String toString() {
+         return String.format("%s", type);
+     }
+     /**
+      * Comparison check.  All fields must match to return true.
+      *
+      * @param rhs another TCommand instance
+      * @return true if all fields are equal
+      */
+     @Override
+     public final boolean equals(final Object rhs) {
+         if (!(rhs instanceof TCommand)) {
+             return false;
+         }
+         TCommand that = (TCommand) rhs;
+         return (type == that.type);
+     }
+     /**
+      * Hashcode uses all fields in equals().
+      *
+      * @return the hash
+      */
+     @Override
+     public int hashCode() {
+         return type;
+     }
+ }
diff --combined src/jexer/TDesktop.java
index 0000000000000000000000000000000000000000,5aa52af74a981f97901c0e09c9d7af4062056fa3..5aa52af74a981f97901c0e09c9d7af4062056fa3
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,258 +1,258 @@@
+ /*
+  * Jexer - Java Text User Interface
+  *
+  * The MIT License (MIT)
+  *
+  * 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"),
+  * to deal in the Software without restriction, including without limitation
+  * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+  * and/or sell copies of the Software, and to permit persons to whom the
+  * Software is furnished to do so, subject to the following conditions:
+  *
+  * The above copyright notice and this permission notice shall be included in
+  * all copies or substantial portions of the Software.
+  *
+  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+  * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+  * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+  * DEALINGS IN THE SOFTWARE.
+  *
+  * @author Kevin Lamonte [kevin.lamonte@gmail.com]
+  * @version 1
+  */
+ package jexer;
+ import jexer.bits.CellAttributes;
+ import jexer.bits.GraphicsChars;
+ import jexer.event.TKeypressEvent;
+ import jexer.event.TMenuEvent;
+ import jexer.event.TMouseEvent;
+ import jexer.event.TResizeEvent;
+ /**
+  * TDesktop is a special-class window that is drawn underneath everything
+  * else.  Like a TWindow, it can contain widgets and perform "background"
+  * processing via onIdle().  But unlike a TWindow, it cannot be hidden,
+  * moved, or resized.
+  *
+  * <p>
+  * Events are passed to TDesktop as follows:
+  * <ul>
+  * <li>Mouse events are seen if they do not cover any other windows.</li>
+  * <li>Keypress events are seen if no other windows are open.</li>
+  * <li>Menu events are seen if no other windows are open.</li>
+  * <li>Command events are seen if no other windows are open.</li>
+  * </ul>
+  */
+ public class TDesktop extends TWindow {
+     // ------------------------------------------------------------------------
+     // Constructors -----------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Public constructor.
+      *
+      * @param parent parent application
+      */
+     public TDesktop(final TApplication parent) {
+         super(parent, "", 0, 0, parent.getScreen().getWidth(),
+             parent.getDesktopBottom() - parent.getDesktopTop());
+         setActive(false);
+     }
+     // ------------------------------------------------------------------------
+     // Event handlers ---------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Handle window/screen resize events.
+      *
+      * @param resize resize event
+      */
+     @Override
+     public void onResize(final TResizeEvent resize) {
+         if (getChildren().size() == 1) {
+             TWidget child = getChildren().get(0);
+             if (!(child instanceof TWindow)) {
+                 // Only one child, resize it to match my size.
+                 child.onResize(new TResizeEvent(TResizeEvent.Type.WIDGET,
+                         getWidth(), getHeight()));
+             }
+         }
+         if (resize.getType() == TResizeEvent.Type.SCREEN) {
+             // Let children see the screen resize
+             for (TWidget widget: getChildren()) {
+                 widget.onResize(resize);
+             }
+         }
+     }
+     /**
+      * Handle mouse button presses.
+      *
+      * @param mouse mouse button event
+      */
+     @Override
+     public void onMouseDown(final TMouseEvent mouse) {
+         this.mouse = mouse;
+         // Pass to children
+         for (TWidget widget: getChildren()) {
+             if (widget.mouseWouldHit(mouse)) {
+                 // Dispatch to this child, also activate it
+                 activate(widget);
+                 // Set x and y relative to the child's coordinates
+                 mouse.setX(mouse.getAbsoluteX() - widget.getAbsoluteX());
+                 mouse.setY(mouse.getAbsoluteY() - widget.getAbsoluteY());
+                 widget.handleEvent(mouse);
+                 return;
+             }
+         }
+     }
+     /**
+      * Handle mouse button releases.
+      *
+      * @param mouse mouse button release event
+      */
+     @Override
+     public void onMouseUp(final TMouseEvent mouse) {
+         this.mouse = mouse;
+         // Pass to children
+         for (TWidget widget: getChildren()) {
+             if (widget.mouseWouldHit(mouse)) {
+                 // Dispatch to this child, also activate it
+                 activate(widget);
+                 // Set x and y relative to the child's coordinates
+                 mouse.setX(mouse.getAbsoluteX() - widget.getAbsoluteX());
+                 mouse.setY(mouse.getAbsoluteY() - widget.getAbsoluteY());
+                 widget.handleEvent(mouse);
+                 return;
+             }
+         }
+     }
+     /**
+      * Handle mouse movements.
+      *
+      * @param mouse mouse motion event
+      */
+     @Override
+     public void onMouseMotion(final TMouseEvent mouse) {
+         this.mouse = mouse;
+         // Default: do nothing, pass to children instead
+         super.onMouseMotion(mouse);
+     }
+     /**
+      * Handle keystrokes.
+      *
+      * @param keypress keystroke event
+      */
+     @Override
+     public void onKeypress(final TKeypressEvent keypress) {
+         // Default: do nothing, pass to children instead
+         super.onKeypress(keypress);
+     }
+     /**
+      * Handle posted menu events.
+      *
+      * @param menu menu event
+      */
+     @Override
+     public void onMenu(final TMenuEvent menu) {
+         // Default: do nothing, pass to children instead
+         super.onMenu(menu);
+     }
+     // ------------------------------------------------------------------------
+     // TWindow ----------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * The default TDesktop draws a hatch character across everything.
+      */
+     @Override
+     public void draw() {
+         CellAttributes background = getTheme().getColor("tdesktop.background");
+         putAll(GraphicsChars.HATCH, background);
+         /*
+         // For debugging, let's see where the desktop bounds really are.
+         putCharXY(0, 0, '0', background);
+         putCharXY(getWidth() - 1, 0, '1', background);
+         putCharXY(0, getHeight() - 1, '2', background);
+         putCharXY(getWidth() - 1, getHeight() - 1, '3', background);
+          */
+     }
+     /**
+      * Hide window.  This is a NOP for TDesktop.
+      */
+     @Override
+     public final void hide() {}
+     /**
+      * Show window.  This is a NOP for TDesktop.
+      */
+     @Override
+     public final void show() {}
+     /**
+      * Called by hide().  This is a NOP for TDesktop.
+      */
+     @Override
+     public final void onHide() {}
+     /**
+      * Called by show().  This is a NOP for TDesktop.
+      */
+     @Override
+     public final void onShow() {}
+     /**
+      * Returns true if the mouse is currently on the close button.
+      *
+      * @return true if mouse is currently on the close button
+      */
+     @Override
+     protected final boolean mouseOnClose() {
+         return false;
+     }
+     /**
+      * Returns true if the mouse is currently on the maximize/restore button.
+      *
+      * @return true if the mouse is currently on the maximize/restore button
+      */
+     @Override
+     protected final boolean mouseOnMaximize() {
+         return false;
+     }
+     /**
+      * Returns true if the mouse is currently on the resizable lower right
+      * corner.
+      *
+      * @return true if the mouse is currently on the resizable lower right
+      * corner
+      */
+     @Override
+     protected final boolean mouseOnResize() {
+         return false;
+     }
+ }
index 0000000000000000000000000000000000000000,322ff5c4e5cb493f93cf356716117d2274156d26..322ff5c4e5cb493f93cf356716117d2274156d26
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,234 +1,234 @@@
+ /*
+  * Jexer - Java Text User Interface
+  *
+  * The MIT License (MIT)
+  *
+  * 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"),
+  * to deal in the Software without restriction, including without limitation
+  * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+  * and/or sell copies of the Software, and to permit persons to whom the
+  * Software is furnished to do so, subject to the following conditions:
+  *
+  * The above copyright notice and this permission notice shall be included in
+  * all copies or substantial portions of the Software.
+  *
+  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+  * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+  * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+  * DEALINGS IN THE SOFTWARE.
+  *
+  * @author Kevin Lamonte [kevin.lamonte@gmail.com]
+  * @version 1
+  */
+ package jexer;
+ import java.io.File;
+ import java.util.ArrayList;
+ import java.util.HashMap;
+ import java.util.List;
+ import java.util.Map;
+ import jexer.bits.StringUtils;
+ /**
+  * TDirectoryList shows the files within a directory.
+  */
+ public class TDirectoryList extends TList {
+     // ------------------------------------------------------------------------
+     // Variables --------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Files in the directory.
+      */
+     private Map<String, File> files;
+     /**
+      * Root path containing files to display.
+      */
+     private File path;
+     /**
+      * The list of filters that a file must match in order to be displayed.
+      */
+     private List<String> filters;
+     // ------------------------------------------------------------------------
+     // Constructors -----------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Public constructor.
+      *
+      * @param parent parent widget
+      * @param path directory path, must be a directory
+      * @param x column relative to parent
+      * @param y row relative to parent
+      * @param width width of text area
+      * @param height height of text area
+      */
+     public TDirectoryList(final TWidget parent, final String path, final int x,
+         final int y, final int width, final int height) {
+         this(parent, path, x, y, width, height, null, null, null);
+     }
+     /**
+      * Public constructor.
+      *
+      * @param parent parent widget
+      * @param path directory path, must be a directory
+      * @param x column relative to parent
+      * @param y row relative to parent
+      * @param width width of text area
+      * @param height height of text area
+      * @param action action to perform when an item is selected (enter or
+      * double-click)
+      */
+     public TDirectoryList(final TWidget parent, final String path, final int x,
+         final int y, final int width, final int height, final TAction action) {
+         this(parent, path, x, y, width, height, action, null, null);
+     }
+     /**
+      * Public constructor.
+      *
+      * @param parent parent widget
+      * @param path directory path, must be a directory
+      * @param x column relative to parent
+      * @param y row relative to parent
+      * @param width width of text area
+      * @param height height of text area
+      * @param action action to perform when an item is selected (enter or
+      * double-click)
+      * @param singleClickAction action to perform when an item is selected
+      * (single-click)
+      */
+     public TDirectoryList(final TWidget parent, final String path, final int x,
+         final int y, final int width, final int height, final TAction action,
+         final TAction singleClickAction) {
+         this(parent, path, x, y, width, height, action, singleClickAction,
+             null);
+     }
+     /**
+      * Public constructor.
+      *
+      * @param parent parent widget
+      * @param path directory path, must be a directory
+      * @param x column relative to parent
+      * @param y row relative to parent
+      * @param width width of text area
+      * @param height height of text area
+      * @param action action to perform when an item is selected (enter or
+      * double-click)
+      * @param singleClickAction action to perform when an item is selected
+      * (single-click)
+      * @param filters a list of strings that files must match to be displayed
+      */
+     public TDirectoryList(final TWidget parent, final String path, final int x,
+         final int y, final int width, final int height, final TAction action,
+         final TAction singleClickAction, final List<String> filters) {
+         super(parent, null, x, y, width, height, action);
+         files = new HashMap<String, File>();
+         this.filters = filters;
+         this.singleClickAction = singleClickAction;
+         setPath(path);
+     }
+     // ------------------------------------------------------------------------
+     // TList ------------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     // TDirectoryList ---------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Set the new path to display.
+      *
+      * @param path new path to list files for
+      */
+     public void setPath(final String path) {
+         this.path = new File(path);
+         List<String> newStrings = new ArrayList<String>();
+         files.clear();
+         // Build a list of files in this directory
+         File [] newFiles = this.path.listFiles();
+         if (newFiles != null) {
+             for (int i = 0; i < newFiles.length; i++) {
+                 if (newFiles[i].getName().startsWith(".")) {
+                     continue;
+                 }
+                 if (newFiles[i].isDirectory()) {
+                     continue;
+                 }
+                 if (filters != null) {
+                     for (String pattern: filters) {
+                         /*
+                         System.err.println("newFiles[i] " +
+                             newFiles[i].getName() + " " + pattern +
+                             " " + newFiles[i].getName().matches(pattern));
+                         */
+                         if (newFiles[i].getName().matches(pattern)) {
+                             String key = renderFile(newFiles[i]);
+                             files.put(key, newFiles[i]);
+                             newStrings.add(key);
+                             break;
+                         }
+                     }
+                 } else {
+                     String key = renderFile(newFiles[i]);
+                     files.put(key, newFiles[i]);
+                     newStrings.add(key);
+                 }
+             }
+         }
+         setList(newStrings);
+         // Select the first entry
+         if (getMaxSelectedIndex() >= 0) {
+             setSelectedIndex(0);
+         }
+     }
+     /**
+      * Get the path that is being displayed.
+      *
+      * @return the path
+      */
+     public File getPath() {
+         path = files.get(getSelected());
+         return path;
+     }
+     /**
+      * Format one of the entries for drawing on the screen.
+      *
+      * @param file the File
+      * @return the line to draw
+      */
+     private String renderFile(final File file) {
+         String name = file.getName();
+         if (StringUtils.width(name) > 20) {
+             name = name.substring(0, 17) + "...";
+         }
+         return String.format("%-20s %5dk", name, (file.length() / 1024));
+     }
+ }
index 0000000000000000000000000000000000000000,668309d5798c411bd977107e086e476255a34616..668309d5798c411bd977107e086e476255a34616
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,789 +1,789 @@@
+ /*
+  * Jexer - Java Text User Interface
+  *
+  * The MIT License (MIT)
+  *
+  * 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"),
+  * to deal in the Software without restriction, including without limitation
+  * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+  * and/or sell copies of the Software, and to permit persons to whom the
+  * Software is furnished to do so, subject to the following conditions:
+  *
+  * The above copyright notice and this permission notice shall be included in
+  * all copies or substantial portions of the Software.
+  *
+  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+  * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+  * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+  * DEALINGS IN THE SOFTWARE.
+  *
+  * @author Kevin Lamonte [kevin.lamonte@gmail.com]
+  * @version 1
+  */
+ package jexer;
+ import java.util.List;
+ import java.util.ResourceBundle;
+ import jexer.bits.Color;
+ import jexer.bits.ColorTheme;
+ import jexer.bits.CellAttributes;
+ import jexer.bits.GraphicsChars;
+ import jexer.event.TKeypressEvent;
+ import jexer.event.TMouseEvent;
+ import static jexer.TKeypress.*;
+ /**
+  * TEditColorThemeWindow provides an easy UI for users to alter the running
+  * color theme.
+  *
+  */
+ public class TEditColorThemeWindow extends TWindow {
+     /**
+      * Translated strings.
+      */
+     private static final ResourceBundle i18n = ResourceBundle.getBundle(TEditColorThemeWindow.class.getName());
+     // ------------------------------------------------------------------------
+     // Variables --------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * The current editing theme.
+      */
+     private ColorTheme editTheme;
+     /**
+      * The left-side list of colors pane.
+      */
+     private TList colorNames;
+     /**
+      * The foreground color.
+      */
+     private ForegroundPicker foreground;
+     /**
+      * The background color.
+      */
+     private BackgroundPicker background;
+     /**
+      * The foreground color picker.
+      */
+     class ForegroundPicker extends TWidget {
+         /**
+          * The selected color.
+          */
+         Color color;
+         /**
+          * The bold flag.
+          */
+         boolean bold;
+         /**
+          * Public constructor.
+          *
+          * @param parent parent widget
+          * @param x column relative to parent
+          * @param y row relative to parent
+          * @param width width of text area
+          * @param height height of text area
+          */
+         public ForegroundPicker(final TWidget parent, final int x,
+             final int y, final int width, final int height) {
+             super(parent, x, y, width, height);
+         }
+         /**
+          * Get the X grid coordinate for this color.
+          *
+          * @param color the Color value
+          * @return the X coordinate
+          */
+         private int getXColorPosition(final Color color) {
+             if (color.equals(Color.BLACK)) {
+                 return 2;
+             } else if (color.equals(Color.BLUE)) {
+                 return 5;
+             } else if (color.equals(Color.GREEN)) {
+                 return 8;
+             } else if (color.equals(Color.CYAN)) {
+                 return 11;
+             } else if (color.equals(Color.RED)) {
+                 return 2;
+             } else if (color.equals(Color.MAGENTA)) {
+                 return 5;
+             } else if (color.equals(Color.YELLOW)) {
+                 return 8;
+             } else if (color.equals(Color.WHITE)) {
+                 return 11;
+             }
+             throw new IllegalArgumentException("Invalid color: " + color);
+         }
+         /**
+          * Get the Y grid coordinate for this color.
+          *
+          * @param color the Color value
+          * @param bold if true use bold color
+          * @return the Y coordinate
+          */
+         private int getYColorPosition(final Color color, final boolean bold) {
+             int dotY = 1;
+             if (color.equals(Color.RED)) {
+                 dotY = 2;
+             } else if (color.equals(Color.MAGENTA)) {
+                 dotY = 2;
+             } else if (color.equals(Color.YELLOW)) {
+                 dotY = 2;
+             } else if (color.equals(Color.WHITE)) {
+                 dotY = 2;
+             }
+             if (bold) {
+                 dotY += 2;
+             }
+             return dotY;
+         }
+         /**
+          * Get the bold value based on Y grid coordinate.
+          *
+          * @param dotY the Y coordinate
+          * @return the bold value
+          */
+         private boolean getBoldFromPosition(final int dotY) {
+             if (dotY > 2) {
+                 return true;
+             }
+             return false;
+         }
+         /**
+          * Get the color based on (X, Y) grid coordinate.
+          *
+          * @param dotX the X coordinate
+          * @param dotY the Y coordinate
+          * @return the Color value
+          */
+         private Color getColorFromPosition(final int dotX, final int dotY) {
+             int y = dotY;
+             if (y > 2) {
+                 y -= 2;
+             }
+             if ((1 <= dotX) && (dotX <= 3) && (y == 1)) {
+                 return Color.BLACK;
+             }
+             if ((4 <= dotX) && (dotX <= 6) && (y == 1)) {
+                 return Color.BLUE;
+             }
+             if ((7 <= dotX) && (dotX <= 9) && (y == 1)) {
+                 return Color.GREEN;
+             }
+             if ((10 <= dotX) && (dotX <= 12) && (y == 1)) {
+                 return Color.CYAN;
+             }
+             if ((1 <= dotX) && (dotX <= 3) && (y == 2)) {
+                 return Color.RED;
+             }
+             if ((4 <= dotX) && (dotX <= 6) && (y == 2)) {
+                 return Color.MAGENTA;
+             }
+             if ((7 <= dotX) && (dotX <= 9) && (y == 2)) {
+                 return Color.YELLOW;
+             }
+             if ((10 <= dotX) && (dotX <= 12) && (y == 2)) {
+                 return Color.WHITE;
+             }
+             throw new IllegalArgumentException("Invalid coordinates: "
+                 + dotX + ", " + dotY);
+         }
+         /**
+          * Draw the foreground colors grid.
+          */
+         @Override
+         public void draw() {
+             CellAttributes border = getWindow().getBorder();
+             CellAttributes background = getWindow().getBackground();
+             CellAttributes attr = new CellAttributes();
+             drawBox(0, 0, getWidth(), getHeight(), border, background, 1,
+                 false);
+             attr.setTo(getTheme().getColor("twindow.background.modal"));
+             if (isActive()) {
+                 attr.setForeColor(getTheme().getColor("tlabel").getForeColor());
+                 attr.setBold(getTheme().getColor("tlabel").isBold());
+             }
+             putStringXY(1, 0, i18n.getString("foregroundLabel"), attr);
+             // Have to draw the colors manually because the int value matches
+             // SGR, not CGA.
+             attr.reset();
+             attr.setForeColor(Color.BLACK);
+             putStringXY(1, 1, "\u2588\u2588\u2588", attr);
+             attr.setForeColor(Color.BLUE);
+             putStringXY(4, 1, "\u2588\u2588\u2588", attr);
+             attr.setForeColor(Color.GREEN);
+             putStringXY(7, 1, "\u2588\u2588\u2588", attr);
+             attr.setForeColor(Color.CYAN);
+             putStringXY(10, 1, "\u2588\u2588\u2588", attr);
+             attr.setForeColor(Color.RED);
+             putStringXY(1, 2, "\u2588\u2588\u2588", attr);
+             attr.setForeColor(Color.MAGENTA);
+             putStringXY(4, 2, "\u2588\u2588\u2588", attr);
+             attr.setForeColor(Color.YELLOW);
+             putStringXY(7, 2, "\u2588\u2588\u2588", attr);
+             attr.setForeColor(Color.WHITE);
+             putStringXY(10, 2, "\u2588\u2588\u2588", attr);
+             attr.setBold(true);
+             attr.setForeColor(Color.BLACK);
+             putStringXY(1, 3, "\u2588\u2588\u2588", attr);
+             attr.setForeColor(Color.BLUE);
+             putStringXY(4, 3, "\u2588\u2588\u2588", attr);
+             attr.setForeColor(Color.GREEN);
+             putStringXY(7, 3, "\u2588\u2588\u2588", attr);
+             attr.setForeColor(Color.CYAN);
+             putStringXY(10, 3, "\u2588\u2588\u2588", attr);
+             attr.setForeColor(Color.RED);
+             putStringXY(1, 4, "\u2588\u2588\u2588", attr);
+             attr.setForeColor(Color.MAGENTA);
+             putStringXY(4, 4, "\u2588\u2588\u2588", attr);
+             attr.setForeColor(Color.YELLOW);
+             putStringXY(7, 4, "\u2588\u2588\u2588", attr);
+             attr.setForeColor(Color.WHITE);
+             putStringXY(10, 4, "\u2588\u2588\u2588", attr);
+             // Draw the dot
+             int dotX = getXColorPosition(color);
+             int dotY = getYColorPosition(color, bold);
+             if (color.equals(Color.BLACK) && !bold) {
+                 // Use white-on-black for black.  All other colors use
+                 // black-on-whatever.
+                 attr.reset();
+                 putCharXY(dotX, dotY, GraphicsChars.CP437[0x07], attr);
+             } else {
+                 attr.setForeColor(color);
+                 attr.setBold(bold);
+                 putCharXY(dotX, dotY, '\u25D8', attr);
+             }
+         }
+         /**
+          * Handle keystrokes.
+          *
+          * @param keypress keystroke event
+          */
+         @Override
+         public void onKeypress(final TKeypressEvent keypress) {
+             if (keypress.equals(kbRight)) {
+                 int dotX = getXColorPosition(color);
+                 int dotY = getYColorPosition(color, bold);
+                 if (dotX < 10) {
+                     dotX += 3;
+                 }
+                 color = getColorFromPosition(dotX, dotY);
+             } else if (keypress.equals(kbLeft)) {
+                 int dotX = getXColorPosition(color);
+                 int dotY = getYColorPosition(color, bold);
+                 if (dotX > 3) {
+                     dotX -= 3;
+                 }
+                 color = getColorFromPosition(dotX, dotY);
+             } else if (keypress.equals(kbUp)) {
+                 int dotX = getXColorPosition(color);
+                 int dotY = getYColorPosition(color, bold);
+                 if (dotY > 1) {
+                     dotY--;
+                 }
+                 color = getColorFromPosition(dotX, dotY);
+                 bold = getBoldFromPosition(dotY);
+             } else if (keypress.equals(kbDown)) {
+                 int dotX = getXColorPosition(color);
+                 int dotY = getYColorPosition(color, bold);
+                 if (dotY < 4) {
+                     dotY++;
+                 }
+                 color = getColorFromPosition(dotX, dotY);
+                 bold = getBoldFromPosition(dotY);
+             } else {
+                 // Pass to my parent
+                 super.onKeypress(keypress);
+                 return;
+             }
+             // Save this update to the local theme.
+             ((TEditColorThemeWindow) getWindow()).saveToEditTheme();
+         }
+         /**
+          * Handle mouse press events.
+          *
+          * @param mouse mouse button press event
+          */
+         @Override
+         public void onMouseDown(final TMouseEvent mouse) {
+             if (mouse.isMouseWheelUp()) {
+                 // Do this like kbUp
+                 int dotX = getXColorPosition(color);
+                 int dotY = getYColorPosition(color, bold);
+                 if (dotY > 1) {
+                     dotY--;
+                 }
+                 color = getColorFromPosition(dotX, dotY);
+                 bold = getBoldFromPosition(dotY);
+             } else if (mouse.isMouseWheelDown()) {
+                 // Do this like kbDown
+                 int dotX = getXColorPosition(color);
+                 int dotY = getYColorPosition(color, bold);
+                 if (dotY < 4) {
+                     dotY++;
+                 }
+                 color = getColorFromPosition(dotX, dotY);
+                 bold = getBoldFromPosition(dotY);
+             } else if ((mouse.getX() > 0)
+                 && (mouse.getX() < getWidth() - 1)
+                 && (mouse.getY() > 0)
+                 && (mouse.getY() < getHeight() - 1)
+             ) {
+                 color = getColorFromPosition(mouse.getX(), mouse.getY());
+                 bold = getBoldFromPosition(mouse.getY());
+             } else {
+                 // Let parent class handle it.
+                 super.onMouseDown(mouse);
+                 return;
+             }
+             // Save this update to the local theme.
+             ((TEditColorThemeWindow) getWindow()).saveToEditTheme();
+         }
+     }
+     /**
+      * The background color picker.
+      */
+     class BackgroundPicker extends TWidget {
+         /**
+          * The selected color.
+          */
+         Color color;
+         /**
+          * Public constructor.
+          *
+          * @param parent parent widget
+          * @param x column relative to parent
+          * @param y row relative to parent
+          * @param width width of text area
+          * @param height height of text area
+          */
+         public BackgroundPicker(final TWidget parent, final int x,
+             final int y, final int width, final int height) {
+             super(parent, x, y, width, height);
+         }
+         /**
+          * Get the X grid coordinate for this color.
+          *
+          * @param color the Color value
+          * @return the X coordinate
+          */
+         private int getXColorPosition(final Color color) {
+             if (color.equals(Color.BLACK)) {
+                 return 2;
+             } else if (color.equals(Color.BLUE)) {
+                 return 5;
+             } else if (color.equals(Color.GREEN)) {
+                 return 8;
+             } else if (color.equals(Color.CYAN)) {
+                 return 11;
+             } else if (color.equals(Color.RED)) {
+                 return 2;
+             } else if (color.equals(Color.MAGENTA)) {
+                 return 5;
+             } else if (color.equals(Color.YELLOW)) {
+                 return 8;
+             } else if (color.equals(Color.WHITE)) {
+                 return 11;
+             }
+             throw new IllegalArgumentException("Invalid color: " + color);
+         }
+         /**
+          * Get the Y grid coordinate for this color.
+          *
+          * @param color the Color value
+          * @return the Y coordinate
+          */
+         private int getYColorPosition(final Color color) {
+             int dotY = 1;
+             if (color.equals(Color.RED)) {
+                 dotY = 2;
+             } else if (color.equals(Color.MAGENTA)) {
+                 dotY = 2;
+             } else if (color.equals(Color.YELLOW)) {
+                 dotY = 2;
+             } else if (color.equals(Color.WHITE)) {
+                 dotY = 2;
+             }
+             return dotY;
+         }
+         /**
+          * Get the color based on (X, Y) grid coordinate.
+          *
+          * @param dotX the X coordinate
+          * @param dotY the Y coordinate
+          * @return the Color value
+          */
+         private Color getColorFromPosition(final int dotX, final int dotY) {
+             if ((1 <= dotX) && (dotX <= 3) && (dotY == 1)) {
+                 return Color.BLACK;
+             }
+             if ((4 <= dotX) && (dotX <= 6) && (dotY == 1)) {
+                 return Color.BLUE;
+             }
+             if ((7 <= dotX) && (dotX <= 9) && (dotY == 1)) {
+                 return Color.GREEN;
+             }
+             if ((10 <= dotX) && (dotX <= 12) && (dotY == 1)) {
+                 return Color.CYAN;
+             }
+             if ((1 <= dotX) && (dotX <= 3) && (dotY == 2)) {
+                 return Color.RED;
+             }
+             if ((4 <= dotX) && (dotX <= 6) && (dotY == 2)) {
+                 return Color.MAGENTA;
+             }
+             if ((7 <= dotX) && (dotX <= 9) && (dotY == 2)) {
+                 return Color.YELLOW;
+             }
+             if ((10 <= dotX) && (dotX <= 12) && (dotY == 2)) {
+                 return Color.WHITE;
+             }
+             throw new IllegalArgumentException("Invalid coordinates: "
+                 + dotX + ", " + dotY);
+         }
+         /**
+          * Draw the background colors grid.
+          */
+         @Override
+         public void draw() {
+             CellAttributes border = getWindow().getBorder();
+             CellAttributes background = getWindow().getBackground();
+             CellAttributes attr = new CellAttributes();
+             drawBox(0, 0, getWidth(), getHeight(), border, background, 1,
+                 false);
+             attr.setTo(getTheme().getColor("twindow.background.modal"));
+             if (isActive()) {
+                 attr.setForeColor(getTheme().getColor("tlabel").getForeColor());
+                 attr.setBold(getTheme().getColor("tlabel").isBold());
+             }
+             putStringXY(1, 0, i18n.getString("backgroundLabel"), attr);
+             // Have to draw the colors manually because the int value matches
+             // SGR, not CGA.
+             attr.reset();
+             attr.setForeColor(Color.BLACK);
+             putStringXY(1, 1, "\u2588\u2588\u2588", attr);
+             attr.setForeColor(Color.BLUE);
+             putStringXY(4, 1, "\u2588\u2588\u2588", attr);
+             attr.setForeColor(Color.GREEN);
+             putStringXY(7, 1, "\u2588\u2588\u2588", attr);
+             attr.setForeColor(Color.CYAN);
+             putStringXY(10, 1, "\u2588\u2588\u2588", attr);
+             attr.setForeColor(Color.RED);
+             putStringXY(1, 2, "\u2588\u2588\u2588", attr);
+             attr.setForeColor(Color.MAGENTA);
+             putStringXY(4, 2, "\u2588\u2588\u2588", attr);
+             attr.setForeColor(Color.YELLOW);
+             putStringXY(7, 2, "\u2588\u2588\u2588", attr);
+             attr.setForeColor(Color.WHITE);
+             putStringXY(10, 2, "\u2588\u2588\u2588", attr);
+             // Draw the dot
+             int dotX = getXColorPosition(color);
+             int dotY = getYColorPosition(color);
+             if (color.equals(Color.BLACK)) {
+                 // Use white-on-black for black.  All other colors use
+                 // black-on-whatever.
+                 attr.reset();
+                 putCharXY(dotX, dotY, GraphicsChars.CP437[0x07], attr);
+             } else {
+                 attr.setForeColor(color);
+                 putCharXY(dotX, dotY, '\u25D8', attr);
+             }
+         }
+         /**
+          * Handle keystrokes.
+          *
+          * @param keypress keystroke event
+          */
+         @Override
+         public void onKeypress(final TKeypressEvent keypress) {
+             if (keypress.equals(kbRight)) {
+                 int dotX = getXColorPosition(color);
+                 int dotY = getYColorPosition(color);
+                 if (dotX < 10) {
+                     dotX += 3;
+                 }
+                 color = getColorFromPosition(dotX, dotY);
+             } else if (keypress.equals(kbLeft)) {
+                 int dotX = getXColorPosition(color);
+                 int dotY = getYColorPosition(color);
+                 if (dotX > 3) {
+                     dotX -= 3;
+                 }
+                 color = getColorFromPosition(dotX, dotY);
+             } else if (keypress.equals(kbUp)) {
+                 int dotX = getXColorPosition(color);
+                 int dotY = getYColorPosition(color);
+                 if (dotY == 2) {
+                     dotY--;
+                 }
+                 color = getColorFromPosition(dotX, dotY);
+             } else if (keypress.equals(kbDown)) {
+                 int dotX = getXColorPosition(color);
+                 int dotY = getYColorPosition(color);
+                 if (dotY == 1) {
+                     dotY++;
+                 }
+                 color = getColorFromPosition(dotX, dotY);
+             } else {
+                 // Pass to my parent
+                 super.onKeypress(keypress);
+             }
+             // Save this update to the local theme.
+             ((TEditColorThemeWindow) getWindow()).saveToEditTheme();
+         }
+         /**
+          * Handle mouse press events.
+          *
+          * @param mouse mouse button press event
+          */
+         @Override
+         public void onMouseDown(final TMouseEvent mouse) {
+             if (mouse.isMouseWheelUp()) {
+                 // Do this like kbUp
+                 int dotX = getXColorPosition(color);
+                 int dotY = getYColorPosition(color);
+                 if (dotY == 2) {
+                     dotY--;
+                 }
+                 color = getColorFromPosition(dotX, dotY);
+             } else if (mouse.isMouseWheelDown()) {
+                 // Do this like kbDown
+                 int dotX = getXColorPosition(color);
+                 int dotY = getYColorPosition(color);
+                 if (dotY == 1) {
+                     dotY++;
+                 }
+                 color = getColorFromPosition(dotX, dotY);
+                 return;
+             } else if ((mouse.getX() > 0)
+                 && (mouse.getX() < getWidth() - 1)
+                 && (mouse.getY() > 0)
+                 && (mouse.getY() < getHeight() - 1)
+             ) {
+                 color = getColorFromPosition(mouse.getX(), mouse.getY());
+             } else {
+                 // Let parent class handle it.
+                 super.onMouseDown(mouse);
+                 return;
+             }
+             // Save this update to the local theme.
+             ((TEditColorThemeWindow) getWindow()).saveToEditTheme();
+         }
+     }
+     // ------------------------------------------------------------------------
+     // Constructors -----------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Public constructor.  The window will be centered on screen.
+      *
+      * @param application the TApplication that manages this window
+      */
+     public TEditColorThemeWindow(final TApplication application) {
+         // Register with the TApplication
+         super(application, i18n.getString("windowTitle"), 0, 0, 60, 18, MODAL);
+         // Initialize with the first color
+         List<String> colors = getTheme().getColorNames();
+         assert (colors.size() > 0);
+         editTheme = new ColorTheme();
+         for (String key: colors) {
+             CellAttributes attr = new CellAttributes();
+             attr.setTo(getTheme().getColor(key));
+             editTheme.setColor(key, attr);
+         }
+         colorNames = addList(colors, 2, 2, 38, getHeight() - 7,
+             new TAction() {
+                 // When the user presses Enter
+                 public void DO() {
+                     refreshFromTheme(colorNames.getSelected());
+                 }
+             },
+             new TAction() {
+                 // When the user navigates with keyboard
+                 public void DO() {
+                     refreshFromTheme(colorNames.getSelected());
+                 }
+             },
+             new TAction() {
+                 // When the user navigates with keyboard
+                 public void DO() {
+                     refreshFromTheme(colorNames.getSelected());
+                 }
+             }
+         );
+         foreground = new ForegroundPicker(this, 42, 1, 14, 6);
+         background = new BackgroundPicker(this, 42, 7, 14, 4);
+         refreshFromTheme(colors.get(0));
+         colorNames.setSelectedIndex(0);
+         addButton(i18n.getString("okButton"), getWidth() - 37, getHeight() - 4,
+             new TAction() {
+                 public void DO() {
+                     ColorTheme global = getTheme();
+                     List<String> colors = editTheme.getColorNames();
+                     for (String key: colors) {
+                         CellAttributes attr = new CellAttributes();
+                         attr.setTo(editTheme.getColor(key));
+                         global.setColor(key, attr);
+                     }
+                     getApplication().closeWindow(TEditColorThemeWindow.this);
+                 }
+             }
+         );
+         addButton(i18n.getString("cancelButton"), getWidth() - 25,
+             getHeight() - 4,
+             new TAction() {
+                 public void DO() {
+                     getApplication().closeWindow(TEditColorThemeWindow.this);
+                 }
+             }
+         );
+         // Default to the color list
+         activate(colorNames);
+         // Add shortcut text
+         newStatusBar(i18n.getString("statusBar"));
+     }
+     // ------------------------------------------------------------------------
+     // Event handlers ---------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Handle keystrokes.
+      *
+      * @param keypress keystroke event
+      */
+     @Override
+     public void onKeypress(final TKeypressEvent keypress) {
+         // Escape - behave like cancel
+         if (keypress.equals(kbEsc)) {
+             getApplication().closeWindow(this);
+             return;
+         }
+         // Pass to my parent
+         super.onKeypress(keypress);
+     }
+     // ------------------------------------------------------------------------
+     // TWindow ----------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Draw me on screen.
+      */
+     @Override
+     public void draw() {
+         super.draw();
+         CellAttributes attr = new CellAttributes();
+         // Draw the label on colorNames
+         attr.setTo(getTheme().getColor("twindow.background.modal"));
+         if (colorNames.isActive()) {
+             attr.setForeColor(getTheme().getColor("tlabel").getForeColor());
+             attr.setBold(getTheme().getColor("tlabel").isBold());
+         }
+         putStringXY(3, 2, i18n.getString("colorName"), attr);
+         // Draw the sample text box
+         attr.reset();
+         attr.setForeColor(foreground.color);
+         attr.setBold(foreground.bold);
+         attr.setBackColor(background.color);
+         putStringXY(getWidth() - 17, getHeight() - 6,
+             i18n.getString("textTextText"), attr);
+         putStringXY(getWidth() - 17, getHeight() - 5,
+             i18n.getString("textTextText"), attr);
+     }
+     // ------------------------------------------------------------------------
+     // TEditColorThemeWindow --------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Set various widgets/values to the editing theme color.
+      *
+      * @param colorName name of color from theme
+      */
+     private void refreshFromTheme(final String colorName) {
+         CellAttributes attr = editTheme.getColor(colorName);
+         foreground.color = attr.getForeColor();
+         foreground.bold = attr.isBold();
+         background.color = attr.getBackColor();
+     }
+     /**
+      * Examines foreground, background, and colorNames and sets the color in
+      * editTheme.
+      */
+     private void saveToEditTheme() {
+         String colorName = colorNames.getSelected();
+         if (colorName == null) {
+             return;
+         }
+         CellAttributes attr = editTheme.getColor(colorName);
+         attr.setForeColor(foreground.color);
+         attr.setBold(foreground.bold);
+         attr.setBackColor(background.color);
+         editTheme.setColor(colorName, attr);
+     }
+ }
index 0000000000000000000000000000000000000000,f4c62206de0a714453450b8665c29b1181c6b805..f4c62206de0a714453450b8665c29b1181c6b805
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,8 +1,8 @@@
+ foregroundLabel=\ Foreground\ 
+ backgroundLabel=\ Background\ 
+ windowTitle=Colors
+ okButton=\ \ &OK\ \ 
+ cancelButton=&Cancel
+ statusBar=Select Colors
+ colorName=Color Name
+ textTextText=Text Text Text
index 0000000000000000000000000000000000000000,a694533bf6df0ed3ee4d6e694f0d1b507deab7a3..a694533bf6df0ed3ee4d6e694f0d1b507deab7a3
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,546 +1,546 @@@
+ /*
+  * Jexer - Java Text User Interface
+  *
+  * The MIT License (MIT)
+  *
+  * 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"),
+  * to deal in the Software without restriction, including without limitation
+  * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+  * and/or sell copies of the Software, and to permit persons to whom the
+  * Software is furnished to do so, subject to the following conditions:
+  *
+  * The above copyright notice and this permission notice shall be included in
+  * all copies or substantial portions of the Software.
+  *
+  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+  * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+  * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+  * DEALINGS IN THE SOFTWARE.
+  *
+  * @author Kevin Lamonte [kevin.lamonte@gmail.com]
+  * @version 1
+  */
+ package jexer;
+ import java.io.IOException;
+ import jexer.bits.CellAttributes;
+ import jexer.event.TKeypressEvent;
+ import jexer.event.TMouseEvent;
+ import jexer.event.TResizeEvent;
+ import jexer.teditor.Document;
+ import jexer.teditor.Line;
+ import jexer.teditor.Word;
+ import static jexer.TKeypress.*;
+ /**
+  * TEditorWidget displays an editable text document.  It is unaware of
+  * scrolling behavior, but can respond to mouse and keyboard events.
+  */
+ public class TEditorWidget extends TWidget {
+     // ------------------------------------------------------------------------
+     // Constants --------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * The number of lines to scroll on mouse wheel up/down.
+      */
+     private static final int wheelScrollSize = 3;
+     // ------------------------------------------------------------------------
+     // Variables --------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * The document being edited.
+      */
+     private Document document;
+     /**
+      * The default color for the TEditor class.
+      */
+     private CellAttributes defaultColor = null;
+     /**
+      * The topmost line number in the visible area.  0-based.
+      */
+     private int topLine = 0;
+     /**
+      * The leftmost column number in the visible area.  0-based.
+      */
+     private int leftColumn = 0;
+     // ------------------------------------------------------------------------
+     // Constructors -----------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Public constructor.
+      *
+      * @param parent parent widget
+      * @param text text on the screen
+      * @param x column relative to parent
+      * @param y row relative to parent
+      * @param width width of text area
+      * @param height height of text area
+      */
+     public TEditorWidget(final TWidget parent, final String text, final int x,
+         final int y, final int width, final int height) {
+         // Set parent and window
+         super(parent, x, y, width, height);
+         setCursorVisible(true);
+         defaultColor = getTheme().getColor("teditor");
+         document = new Document(text, defaultColor);
+     }
+     // ------------------------------------------------------------------------
+     // TWidget ----------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Draw the text box.
+      */
+     @Override
+     public void draw() {
+         for (int i = 0; i < getHeight(); i++) {
+             // Background line
+             getScreen().hLineXY(0, i, getWidth(), ' ', defaultColor);
+             // Now draw document's line
+             if (topLine + i < document.getLineCount()) {
+                 Line line = document.getLine(topLine + i);
+                 int x = 0;
+                 for (Word word: line.getWords()) {
+                     // For now, we are cheating: draw outside the left region
+                     // if needed and let screen do the clipping.
+                     getScreen().putStringXY(x - leftColumn, i, word.getText(),
+                         word.getColor());
+                     x += word.getDisplayLength();
+                     if (x - leftColumn > getWidth()) {
+                         break;
+                     }
+                 }
+             }
+         }
+     }
+     /**
+      * Handle mouse press events.
+      *
+      * @param mouse mouse button press event
+      */
+     @Override
+     public void onMouseDown(final TMouseEvent mouse) {
+         if (mouse.isMouseWheelUp()) {
+             for (int i = 0; i < wheelScrollSize; i++) {
+                 if (topLine > 0) {
+                     topLine--;
+                     alignDocument(false);
+                 }
+             }
+             return;
+         }
+         if (mouse.isMouseWheelDown()) {
+             for (int i = 0; i < wheelScrollSize; i++) {
+                 if (topLine < document.getLineCount() - 1) {
+                     topLine++;
+                     alignDocument(true);
+                 }
+             }
+             return;
+         }
+         if (mouse.isMouse1()) {
+             // Set the row and column
+             int newLine = topLine + mouse.getY();
+             int newX = leftColumn + mouse.getX();
+             if (newLine > document.getLineCount() - 1) {
+                 // Go to the end
+                 document.setLineNumber(document.getLineCount() - 1);
+                 document.end();
+                 if (newLine > document.getLineCount() - 1) {
+                     setCursorY(document.getLineCount() - 1 - topLine);
+                 } else {
+                     setCursorY(mouse.getY());
+                 }
+                 alignCursor();
+                 return;
+             }
+             document.setLineNumber(newLine);
+             setCursorY(mouse.getY());
+             if (newX >= document.getCurrentLine().getDisplayLength()) {
+                 document.end();
+                 alignCursor();
+             } else {
+                 document.setCursor(newX);
+                 setCursorX(mouse.getX());
+             }
+             return;
+         }
+         // Pass to children
+         super.onMouseDown(mouse);
+     }
+     /**
+      * Handle keystrokes.
+      *
+      * @param keypress keystroke event
+      */
+     @Override
+     public void onKeypress(final TKeypressEvent keypress) {
+         if (keypress.equals(kbLeft)) {
+             document.left();
+             alignTopLine(false);
+         } else if (keypress.equals(kbRight)) {
+             document.right();
+             alignTopLine(true);
+         } else if (keypress.equals(kbAltLeft)
+             || keypress.equals(kbCtrlLeft)
+         ) {
+             document.backwardsWord();
+             alignTopLine(false);
+         } else if (keypress.equals(kbAltRight)
+             || keypress.equals(kbCtrlRight)
+         ) {
+             document.forwardsWord();
+             alignTopLine(true);
+         } else if (keypress.equals(kbUp)) {
+             document.up();
+             alignTopLine(false);
+         } else if (keypress.equals(kbDown)) {
+             document.down();
+             alignTopLine(true);
+         } else if (keypress.equals(kbPgUp)) {
+             document.up(getHeight() - 1);
+             alignTopLine(false);
+         } else if (keypress.equals(kbPgDn)) {
+             document.down(getHeight() - 1);
+             alignTopLine(true);
+         } else if (keypress.equals(kbHome)) {
+             if (document.home()) {
+                 leftColumn = 0;
+                 if (leftColumn < 0) {
+                     leftColumn = 0;
+                 }
+                 setCursorX(0);
+             }
+         } else if (keypress.equals(kbEnd)) {
+             if (document.end()) {
+                 alignCursor();
+             }
+         } else if (keypress.equals(kbCtrlHome)) {
+             document.setLineNumber(0);
+             document.home();
+             topLine = 0;
+             leftColumn = 0;
+             setCursorX(0);
+             setCursorY(0);
+         } else if (keypress.equals(kbCtrlEnd)) {
+             document.setLineNumber(document.getLineCount() - 1);
+             document.end();
+             alignTopLine(false);
+         } else if (keypress.equals(kbIns)) {
+             document.setOverwrite(!document.getOverwrite());
+         } else if (keypress.equals(kbDel)) {
+             document.del();
+             alignCursor();
+         } else if (keypress.equals(kbBackspace)
+             || keypress.equals(kbBackspaceDel)
+         ) {
+             document.backspace();
+             alignTopLine(false);
+         } else if (keypress.equals(kbTab)) {
+             // TODO: tab character.  For now just add spaces until we hit
+             // modulo 8.
+             for (int i = document.getCursor(); (i + 1) % 8 != 0; i++) {
+                 document.addChar(' ');
+             }
+             alignCursor();
+         } else if (keypress.equals(kbEnter)) {
+             document.enter();
+             alignTopLine(true);
+         } else if (!keypress.getKey().isFnKey()
+             && !keypress.getKey().isAlt()
+             && !keypress.getKey().isCtrl()
+         ) {
+             // Plain old keystroke, process it
+             document.addChar(keypress.getKey().getChar());
+             alignCursor();
+         } else {
+             // Pass other keys (tab etc.) on to TWidget
+             super.onKeypress(keypress);
+         }
+     }
+     /**
+      * Method that subclasses can override to handle window/screen resize
+      * events.
+      *
+      * @param resize resize event
+      */
+     @Override
+     public void onResize(final TResizeEvent resize) {
+         // Change my width/height, and pull the cursor in as needed.
+         if (resize.getType() == TResizeEvent.Type.WIDGET) {
+             setWidth(resize.getWidth());
+             setHeight(resize.getHeight());
+             // See if the cursor is now outside the window, and if so move
+             // things.
+             if (getCursorX() >= getWidth()) {
+                 leftColumn += getCursorX() - (getWidth() - 1);
+                 setCursorX(getWidth() - 1);
+             }
+             if (getCursorY() >= getHeight()) {
+                 topLine += getCursorY() - (getHeight() - 1);
+                 setCursorY(getHeight() - 1);
+             }
+         } else {
+             // Let superclass handle it
+             super.onResize(resize);
+         }
+     }
+     // ------------------------------------------------------------------------
+     // TEditorWidget ----------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Align visible area with document current line.
+      *
+      * @param topLineIsTop if true, make the top visible line the document
+      * current line if it was off-screen.  If false, make the bottom visible
+      * line the document current line.
+      */
+     private void alignTopLine(final boolean topLineIsTop) {
+         int line = document.getLineNumber();
+         if ((line < topLine) || (line > topLine + getHeight() - 1)) {
+             // Need to move topLine to bring document back into view.
+             if (topLineIsTop) {
+                 topLine = line - (getHeight() - 1);
+                 if (topLine < 0) {
+                     topLine = 0;
+                 }
+                 assert (topLine >= 0);
+             } else {
+                 topLine = line;
+                 assert (topLine >= 0);
+             }
+         }
+         /*
+         System.err.println("line " + line + " topLine " + topLine);
+         */
+         // Document is in view, let's set cursorY
+         assert (line >= topLine);
+         setCursorY(line - topLine);
+         alignCursor();
+     }
+     /**
+      * Align document current line with visible area.
+      *
+      * @param topLineIsTop if true, make the top visible line the document
+      * current line if it was off-screen.  If false, make the bottom visible
+      * line the document current line.
+      */
+     private void alignDocument(final boolean topLineIsTop) {
+         int line = document.getLineNumber();
+         int cursor = document.getCursor();
+         if ((line < topLine) || (line > topLine + getHeight() - 1)) {
+             // Need to move document to ensure it fits view.
+             if (topLineIsTop) {
+                 document.setLineNumber(topLine);
+             } else {
+                 document.setLineNumber(topLine + (getHeight() - 1));
+             }
+             if (cursor < document.getCurrentLine().getDisplayLength()) {
+                 document.setCursor(cursor);
+             }
+         }
+         /*
+         System.err.println("getLineNumber() " + document.getLineNumber() +
+             " topLine " + topLine);
+         */
+         // Document is in view, let's set cursorY
+         setCursorY(document.getLineNumber() - topLine);
+         alignCursor();
+     }
+     /**
+      * Align visible cursor with document cursor.
+      */
+     private void alignCursor() {
+         int width = getWidth();
+         int desiredX = document.getCursor() - leftColumn;
+         if (desiredX < 0) {
+             // We need to push the screen to the left.
+             leftColumn = document.getCursor();
+         } else if (desiredX > width - 1) {
+             // We need to push the screen to the right.
+             leftColumn = document.getCursor() - (width - 1);
+         }
+         /*
+         System.err.println("document cursor " + document.getCursor() +
+             " leftColumn " + leftColumn);
+         */
+         setCursorX(document.getCursor() - leftColumn);
+     }
+     /**
+      * Get the number of lines in the underlying Document.
+      *
+      * @return the number of lines
+      */
+     public int getLineCount() {
+         return document.getLineCount();
+     }
+     /**
+      * Get the current visible top row number.  1-based.
+      *
+      * @return the visible top row number.  Row 1 is the first row.
+      */
+     public int getVisibleRowNumber() {
+         return topLine + 1;
+     }
+     /**
+      * Set the current visible row number.  1-based.
+      *
+      * @param row the new visible row number.  Row 1 is the first row.
+      */
+     public void setVisibleRowNumber(final int row) {
+         assert (row > 0);
+         if ((row > 0) && (row < document.getLineCount())) {
+             topLine = row - 1;
+             alignDocument(true);
+         }
+     }
+     /**
+      * Get the current editing row number.  1-based.
+      *
+      * @return the editing row number.  Row 1 is the first row.
+      */
+     public int getEditingRowNumber() {
+         return document.getLineNumber() + 1;
+     }
+     /**
+      * Set the current editing row number.  1-based.
+      *
+      * @param row the new editing row number.  Row 1 is the first row.
+      */
+     public void setEditingRowNumber(final int row) {
+         assert (row > 0);
+         if ((row > 0) && (row < document.getLineCount())) {
+             document.setLineNumber(row - 1);
+             alignTopLine(true);
+         }
+     }
+     /**
+      * Set the current visible column number.  1-based.
+      *
+      * @return the visible column number.  Column 1 is the first column.
+      */
+     public int getVisibleColumnNumber() {
+         return leftColumn + 1;
+     }
+     /**
+      * Set the current visible column number.  1-based.
+      *
+      * @param column the new visible column number.  Column 1 is the first
+      * column.
+      */
+     public void setVisibleColumnNumber(final int column) {
+         assert (column > 0);
+         if ((column > 0) && (column < document.getLineLengthMax())) {
+             leftColumn = column - 1;
+             alignDocument(true);
+         }
+     }
+     /**
+      * Get the current editing column number.  1-based.
+      *
+      * @return the editing column number.  Column 1 is the first column.
+      */
+     public int getEditingColumnNumber() {
+         return document.getCursor() + 1;
+     }
+     /**
+      * Set the current editing column number.  1-based.
+      *
+      * @param column the new editing column number.  Column 1 is the first
+      * column.
+      */
+     public void setEditingColumnNumber(final int column) {
+         if ((column > 0) && (column < document.getLineLength())) {
+             document.setCursor(column - 1);
+             alignCursor();
+         }
+     }
+     /**
+      * Get the maximum possible row number.  1-based.
+      *
+      * @return the maximum row number.  Row 1 is the first row.
+      */
+     public int getMaximumRowNumber() {
+         return document.getLineCount() + 1;
+     }
+     /**
+      * Get the maximum possible column number.  1-based.
+      *
+      * @return the maximum column number.  Column 1 is the first column.
+      */
+     public int getMaximumColumnNumber() {
+         return document.getLineLengthMax() + 1;
+     }
+     /**
+      * Get the dirty value.
+      *
+      * @return true if the buffer is dirty
+      */
+     public boolean isDirty() {
+         return document.isDirty();
+     }
+     /**
+      * Save contents to file.
+      *
+      * @param filename file to save to
+      * @throws IOException if a java.io operation throws
+      */
+     public void saveToFilename(final String filename) throws IOException {
+         document.saveToFilename(filename);
+     }
+ }
index 0000000000000000000000000000000000000000,d78185c32f3096cd615f345d9731751d285fc3b6..d78185c32f3096cd615f345d9731751d285fc3b6
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,452 +1,452 @@@
+ /*
+  * Jexer - Java Text User Interface
+  *
+  * The MIT License (MIT)
+  *
+  * 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"),
+  * to deal in the Software without restriction, including without limitation
+  * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+  * and/or sell copies of the Software, and to permit persons to whom the
+  * Software is furnished to do so, subject to the following conditions:
+  *
+  * The above copyright notice and this permission notice shall be included in
+  * all copies or substantial portions of the Software.
+  *
+  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+  * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+  * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+  * DEALINGS IN THE SOFTWARE.
+  *
+  * @author Kevin Lamonte [kevin.lamonte@gmail.com]
+  * @version 1
+  */
+ package jexer;
+ import java.io.File;
+ import java.io.IOException;
+ import java.text.MessageFormat;
+ import java.util.ResourceBundle;
+ import java.util.Scanner;
+ import jexer.TApplication;
+ import jexer.TEditorWidget;
+ import jexer.THScroller;
+ import jexer.TScrollableWindow;
+ import jexer.TVScroller;
+ import jexer.TWidget;
+ import jexer.bits.CellAttributes;
+ import jexer.bits.GraphicsChars;
+ import jexer.event.TCommandEvent;
+ import jexer.event.TKeypressEvent;
+ import jexer.event.TMouseEvent;
+ import jexer.event.TResizeEvent;
+ import static jexer.TCommand.*;
+ import static jexer.TKeypress.*;
+ /**
+  * TEditorWindow is a basic text file editor.
+  */
+ public class TEditorWindow extends TScrollableWindow {
+     /**
+      * Translated strings.
+      */
+     private static final ResourceBundle i18n = ResourceBundle.getBundle(TEditorWindow.class.getName());
+     // ------------------------------------------------------------------------
+     // Variables --------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Hang onto my TEditor so I can resize it with the window.
+      */
+     private TEditorWidget editField;
+     /**
+      * The fully-qualified name of the file being edited.
+      */
+     private String filename = "";
+     /**
+      * If true, hide the mouse after typing a keystroke.
+      */
+     private boolean hideMouseWhenTyping = true;
+     /**
+      * If true, the mouse should not be displayed because a keystroke was
+      * typed.
+      */
+     private boolean typingHidMouse = false;
+     // ------------------------------------------------------------------------
+     // Constructors -----------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Public constructor sets window title.
+      *
+      * @param parent the main application
+      * @param title the window title
+      */
+     public TEditorWindow(final TApplication parent, final String title) {
+         super(parent, title, 0, 0, parent.getScreen().getWidth(),
+             parent.getDesktopBottom() - parent.getDesktopTop(), RESIZABLE);
+         editField = addEditor("", 0, 0, getWidth() - 2, getHeight() - 2);
+         setupAfterEditor();
+     }
+     /**
+      * Public constructor sets window title and contents.
+      *
+      * @param parent the main application
+      * @param title the window title, usually a filename
+      * @param contents the data for the editing window, usually the file data
+      */
+     public TEditorWindow(final TApplication parent, final String title,
+         final String contents) {
+         super(parent, title, 0, 0, parent.getScreen().getWidth(),
+             parent.getDesktopBottom() - parent.getDesktopTop(), RESIZABLE);
+         filename = title;
+         editField = addEditor(contents, 0, 0, getWidth() - 2, getHeight() - 2);
+         setupAfterEditor();
+     }
+     /**
+      * Public constructor opens a file.
+      *
+      * @param parent the main application
+      * @param file the file to open
+      * @throws IOException if a java.io operation throws
+      */
+     public TEditorWindow(final TApplication parent,
+         final File file) throws IOException {
+         super(parent, file.getName(), 0, 0, parent.getScreen().getWidth(),
+             parent.getDesktopBottom() - parent.getDesktopTop(), RESIZABLE);
+         filename = file.getName();
+         String contents = readFileData(file);
+         editField = addEditor(contents, 0, 0, getWidth() - 2, getHeight() - 2);
+         setupAfterEditor();
+     }
+     /**
+      * Public constructor.
+      *
+      * @param parent the main application
+      */
+     public TEditorWindow(final TApplication parent) {
+         this(parent, i18n.getString("newTextDocument"));
+     }
+     // ------------------------------------------------------------------------
+     // TWindow ----------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Draw the window.
+      */
+     @Override
+     public void draw() {
+         // Draw as normal.
+         super.draw();
+         // Add the row:col on the bottom row
+         CellAttributes borderColor = getBorder();
+         String location = String.format(" %d:%d ",
+             editField.getEditingRowNumber(),
+             editField.getEditingColumnNumber());
+         int colon = location.indexOf(':');
+         putStringXY(10 - colon, getHeight() - 1, location, borderColor);
+         if (editField.isDirty()) {
+             putCharXY(2, getHeight() - 1, GraphicsChars.OCTOSTAR, borderColor);
+         }
+     }
+     /**
+      * Handle mouse press events.
+      *
+      * @param mouse mouse button press event
+      */
+     @Override
+     public void onMouseDown(final TMouseEvent mouse) {
+         // Use TWidget's code to pass the event to the children.
+         super.onMouseDown(mouse);
+         if (hideMouseWhenTyping) {
+             typingHidMouse = false;
+         }
+         if (mouseOnEditor(mouse)) {
+             // The editor might have changed, update the scollbars.
+             setBottomValue(editField.getMaximumRowNumber());
+             setVerticalValue(editField.getVisibleRowNumber());
+             setRightValue(editField.getMaximumColumnNumber());
+             setHorizontalValue(editField.getEditingColumnNumber());
+         } else {
+             if (mouse.isMouseWheelUp() || mouse.isMouseWheelDown()) {
+                 // Vertical scrollbar actions
+                 editField.setVisibleRowNumber(getVerticalValue());
+             }
+         }
+     }
+     /**
+      * Handle mouse release events.
+      *
+      * @param mouse mouse button release event
+      */
+     @Override
+     public void onMouseUp(final TMouseEvent mouse) {
+         // Use TWidget's code to pass the event to the children.
+         super.onMouseUp(mouse);
+         if (hideMouseWhenTyping) {
+             typingHidMouse = false;
+         }
+         if (mouse.isMouse1() && mouseOnVerticalScroller(mouse)) {
+             // Clicked on vertical scrollbar
+             editField.setVisibleRowNumber(getVerticalValue());
+         }
+         if (mouse.isMouse1() && mouseOnHorizontalScroller(mouse)) {
+             // Clicked on horizontal scrollbar
+             editField.setVisibleColumnNumber(getHorizontalValue());
+             setHorizontalValue(editField.getVisibleColumnNumber());
+         }
+     }
+     /**
+      * Method that subclasses can override to handle mouse movements.
+      *
+      * @param mouse mouse motion event
+      */
+     @Override
+     public void onMouseMotion(final TMouseEvent mouse) {
+         // Use TWidget's code to pass the event to the children.
+         super.onMouseMotion(mouse);
+         if (hideMouseWhenTyping) {
+             typingHidMouse = false;
+         }
+         if (mouseOnEditor(mouse) && mouse.isMouse1()) {
+             // The editor might have changed, update the scollbars.
+             setBottomValue(editField.getMaximumRowNumber());
+             setVerticalValue(editField.getVisibleRowNumber());
+             setRightValue(editField.getMaximumColumnNumber());
+             setHorizontalValue(editField.getEditingColumnNumber());
+         } else {
+             if (mouse.isMouse1() && mouseOnVerticalScroller(mouse)) {
+                 // Clicked/dragged on vertical scrollbar
+                 editField.setVisibleRowNumber(getVerticalValue());
+             }
+             if (mouse.isMouse1() && mouseOnHorizontalScroller(mouse)) {
+                 // Clicked/dragged on horizontal scrollbar
+                 editField.setVisibleColumnNumber(getHorizontalValue());
+                 setHorizontalValue(editField.getVisibleColumnNumber());
+             }
+         }
+     }
+     /**
+      * Handle keystrokes.
+      *
+      * @param keypress keystroke event
+      */
+     @Override
+     public void onKeypress(final TKeypressEvent keypress) {
+         if (hideMouseWhenTyping) {
+             typingHidMouse = true;
+         }
+         // Use TWidget's code to pass the event to the children.
+         super.onKeypress(keypress);
+         // The editor might have changed, update the scollbars.
+         setBottomValue(editField.getMaximumRowNumber());
+         setVerticalValue(editField.getVisibleRowNumber());
+         setRightValue(editField.getMaximumColumnNumber());
+         setHorizontalValue(editField.getEditingColumnNumber());
+     }
+     /**
+      * Handle window/screen resize events.
+      *
+      * @param event resize event
+      */
+     @Override
+     public void onResize(final TResizeEvent event) {
+         if (event.getType() == TResizeEvent.Type.WIDGET) {
+             // Resize the text field
+             TResizeEvent editSize = new TResizeEvent(TResizeEvent.Type.WIDGET,
+                 event.getWidth() - 2, event.getHeight() - 2);
+             editField.onResize(editSize);
+             // Have TScrollableWindow handle the scrollbars
+             super.onResize(event);
+             return;
+         }
+         // Pass to children instead
+         for (TWidget widget: getChildren()) {
+             widget.onResize(event);
+         }
+     }
+     /**
+      * Method that subclasses can override to handle posted command events.
+      *
+      * @param command command event
+      */
+     @Override
+     public void onCommand(final TCommandEvent command) {
+         if (command.equals(cmOpen)) {
+             try {
+                 String filename = fileOpenBox(".");
+                 if (filename != null) {
+                     try {
+                         String contents = readFileData(filename);
+                         new TEditorWindow(getApplication(), filename, contents);
+                     } catch (IOException e) {
+                         messageBox(i18n.getString("errorDialogTitle"),
+                             MessageFormat.format(i18n.
+                                 getString("errorReadingFile"), e.getMessage()));
+                     }
+                 }
+             } catch (IOException e) {
+                 messageBox(i18n.getString("errorDialogTitle"),
+                     MessageFormat.format(i18n.
+                         getString("errorOpeningFileDialog"), e.getMessage()));
+             }
+             return;
+         }
+         if (command.equals(cmSave)) {
+             if (filename.length() > 0) {
+                 try {
+                     editField.saveToFilename(filename);
+                 } catch (IOException e) {
+                 messageBox(i18n.getString("errorDialogTitle"),
+                     MessageFormat.format(i18n.
+                         getString("errorSavingFile"), e.getMessage()));
+                 }
+             }
+             return;
+         }
+         // Didn't handle it, let children get it instead
+         super.onCommand(command);
+     }
+     /**
+      * Returns true if this window does not want the application-wide mouse
+      * cursor drawn over it.
+      *
+      * @return true if this window does not want the application-wide mouse
+      * cursor drawn over it
+      */
+     @Override
+     public boolean hasHiddenMouse() {
+         return (super.hasHiddenMouse() || typingHidMouse);
+     }
+     // ------------------------------------------------------------------------
+     // TEditorWindow ----------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Setup other fields after the editor is created.
+      */
+     private void setupAfterEditor() {
+         hScroller = new THScroller(this, 17, getHeight() - 2, getWidth() - 20);
+         vScroller = new TVScroller(this, getWidth() - 2, 0, getHeight() - 2);
+         setMinimumWindowWidth(25);
+         setMinimumWindowHeight(10);
+         setTopValue(1);
+         setBottomValue(editField.getMaximumRowNumber());
+         setLeftValue(1);
+         setRightValue(editField.getMaximumColumnNumber());
+         statusBar = newStatusBar(i18n.getString("statusBar"));
+         statusBar.addShortcutKeypress(kbF1, cmHelp,
+             i18n.getString("statusBarHelp"));
+         statusBar.addShortcutKeypress(kbF2, cmSave,
+             i18n.getString("statusBarSave"));
+         statusBar.addShortcutKeypress(kbF3, cmOpen,
+             i18n.getString("statusBarOpen"));
+         statusBar.addShortcutKeypress(kbF10, cmMenu,
+             i18n.getString("statusBarMenu"));
+         // Hide mouse when typing option
+         if (System.getProperty("jexer.TEditor.hideMouseWhenTyping",
+                 "true").equals("false")) {
+             hideMouseWhenTyping = false;
+         }
+     }
+     /**
+      * Read file data into a string.
+      *
+      * @param file the file to open
+      * @return the file contents
+      * @throws IOException if a java.io operation throws
+      */
+     private String readFileData(final File file) throws IOException {
+         StringBuilder fileContents = new StringBuilder();
+         Scanner scanner = new Scanner(file);
+         String EOL = System.getProperty("line.separator");
+         try {
+             while (scanner.hasNextLine()) {
+                 fileContents.append(scanner.nextLine() + EOL);
+             }
+             return fileContents.toString();
+         } finally {
+             scanner.close();
+         }
+     }
+     /**
+      * Read file data into a string.
+      *
+      * @param filename the file to open
+      * @return the file contents
+      * @throws IOException if a java.io operation throws
+      */
+     private String readFileData(final String filename) throws IOException {
+         return readFileData(new File(filename));
+     }
+     /**
+      * Check if a mouse press/release/motion event coordinate is over the
+      * editor.
+      *
+      * @param mouse a mouse-based event
+      * @return whether or not the mouse is on the editor
+      */
+     private boolean mouseOnEditor(final TMouseEvent mouse) {
+         if ((mouse.getAbsoluteX() >= getAbsoluteX() + 1)
+             && (mouse.getAbsoluteX() <  getAbsoluteX() + getWidth() - 1)
+             && (mouse.getAbsoluteY() >= getAbsoluteY() + 1)
+             && (mouse.getAbsoluteY() <  getAbsoluteY() + getHeight() - 1)
+         ) {
+             return true;
+         }
+         return false;
+     }
+ }
index 0000000000000000000000000000000000000000,d18b078a5c83894a4b410427ddb74679eea8d8c9..d18b078a5c83894a4b410427ddb74679eea8d8c9
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,10 +1,10 @@@
+ statusBar=Editor
+ statusBarHelp=Help
+ statusBarSave=Save
+ statusBarOpen=Open
+ statusBarMenu=Menu
+ newTextDocument=New Text Document
+ errorDialogTitle=Error
+ errorReadingFile=Error reading file: {0}
+ errorOpeningFileDialog=Error opening file dialog: {0}
+ errorSavingFile=Error saving file: {0}
index 0000000000000000000000000000000000000000,227aceb5764b92d26a0cac983030a0def359a402..227aceb5764b92d26a0cac983030a0def359a402
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,207 +1,207 @@@
+ /*
+  * Jexer - Java Text User Interface
+  *
+  * The MIT License (MIT)
+  *
+  * 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"),
+  * to deal in the Software without restriction, including without limitation
+  * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+  * and/or sell copies of the Software, and to permit persons to whom the
+  * Software is furnished to do so, subject to the following conditions:
+  *
+  * The above copyright notice and this permission notice shall be included in
+  * all copies or substantial portions of the Software.
+  *
+  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+  * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+  * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+  * DEALINGS IN THE SOFTWARE.
+  *
+  * @author Kevin Lamonte [kevin.lamonte@gmail.com]
+  * @version 1
+  */
+ package jexer;
+ import java.io.FileWriter;
+ import java.io.IOException;
+ import java.io.PrintWriter;
+ import java.text.MessageFormat;
+ import java.util.ArrayList;
+ import java.util.Date;
+ import java.util.ResourceBundle;
+ import jexer.bits.CellAttributes;
+ /**
+  * TExceptionDialog displays an exception and its stack trace to the user,
+  * and provides a means to save a troubleshooting report for support.
+  */
+ public class TExceptionDialog extends TWindow {
+     /**
+      * Translated strings.
+      */
+     private static final ResourceBundle i18n = ResourceBundle.getBundle(TExceptionDialog.class.getName());
+     // ------------------------------------------------------------------------
+     // Constants --------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     // Variables --------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * The exception.  We will actually make it Throwable, for the unlikely
+      * event we catch an Error rather than an Exception.
+      */
+     private Throwable exception;
+     /**
+      * The exception's stack trace.
+      */
+     private TList stackTrace;
+     // ------------------------------------------------------------------------
+     // Constructors -----------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Public constructor.
+      *
+      * @param application TApplication that manages this window
+      * @param exception the exception to display
+      */
+     public TExceptionDialog(final TApplication application,
+         final Throwable exception) {
+         super(application, i18n.getString("windowTitle"),
+             1, 1, 70, 20, CENTERED | MODAL);
+         this.exception = exception;
+         addLabel(i18n.getString("captionLine1"), 1, 1,
+             "twindow.background.modal");
+         addLabel(i18n.getString("captionLine2"), 1, 2,
+             "twindow.background.modal");
+         addLabel(i18n.getString("captionLine3"), 1, 3,
+             "twindow.background.modal");
+         addLabel(i18n.getString("captionLine4"), 1, 4,
+             "twindow.background.modal");
+         addLabel(MessageFormat.format(i18n.getString("exceptionString"),
+                 exception.getClass().getName(), exception.getMessage()),
+             2, 6, "ttext", false);
+         ArrayList<String> stackTraceStrings = new ArrayList<String>();
+         StackTraceElement [] stack = exception.getStackTrace();
+         for (int i = 0; i < stack.length; i++) {
+             stackTraceStrings.add(stack[i].toString());
+         }
+         stackTrace = addList(stackTraceStrings, 2, 7, getWidth() - 6, 8);
+         // Buttons
+         addButton(i18n.getString("saveButton"), 19, getHeight() - 4,
+             new TAction() {
+                 public void DO() {
+                     saveToFile();
+                 }
+             });
+         TButton closeButton = addButton(i18n.getString("closeButton"),
+             35, getHeight() - 4,
+             new TAction() {
+                 public void DO() {
+                     // Don't do anything, just close the window.
+                     TExceptionDialog.this.close();
+                 }
+             });
+         // Save this for last: make the close button default action.
+         activate(closeButton);
+     }
+     // ------------------------------------------------------------------------
+     // TWindow ----------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Draw the exception message background.
+      */
+     @Override
+     public void draw() {
+         // Draw window and border.
+         super.draw();
+         CellAttributes boxColor = getTheme().getColor("ttext");
+         hLineXY(3, 7, getWidth() - 6, ' ', boxColor);
+     }
+     // ------------------------------------------------------------------------
+     // TExceptionDialog -------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Save a troubleshooting report to file.  Note that we do NOT translate
+      * the strings within the error report.
+      */
+     private void saveToFile() {
+         // Prompt for filename.
+         PrintWriter writer = null;
+         try {
+             String filename = fileSaveBox(".");
+             if (filename == null) {
+                 // User cancelled, bail out.
+                 return;
+             }
+             writer = new PrintWriter(new FileWriter(filename));
+             writer.write("Date: " + new Date(System.currentTimeMillis())
+                 + "\n");
+             // System properties
+             writer.write("System properties:\n");
+             writer.write("-----------------------------------\n");
+             System.getProperties().store(writer, null);
+             writer.write("-----------------------------------\n");
+             writer.write("\n");
+             // The exception we caught
+             writer.write("Caught exception:\n");
+             writer.write("-----------------------------------\n");
+             exception.printStackTrace(writer);
+             writer.write("-----------------------------------\n");
+             writer.write("\n");
+             // The exception's cause, if it was set
+             if (exception.getCause() != null) {
+                 writer.write("Caught exception's cause:\n");
+                 writer.write("-----------------------------------\n");
+                 exception.getCause().printStackTrace(writer);
+                 writer.write("-----------------------------------\n");
+             }
+             writer.write("\n");
+             // The UI stack trace
+             writer.write("UI stack trace:\n");
+             writer.write("-----------------------------------\n");
+             (new Throwable("UI Thread")).printStackTrace(writer);
+             writer.write("-----------------------------------\n");
+             writer.write("\n");
+             writer.close();
+         } catch (IOException e) {
+             messageBox(i18n.getString("errorDialogTitle"),
+                 MessageFormat.format(i18n.
+                     getString("errorSavingFile"), e.getMessage()));
+         } finally {
+             if (writer != null) {
+                 writer.close();
+                 writer = null;
+             }
+         }
+     }
+ }
index 0000000000000000000000000000000000000000,d07998cf2931c6956becc0d2a177f90d5a153d8c..d07998cf2931c6956becc0d2a177f90d5a153d8c
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,15 +1,15 @@@
+ windowTitle=Java Exception Caught
+ statusBar=Exception
+ captionLine1=An error has occurred.  This may be due to a programming bug, but
+ captionLine2=could also be a correctable or temporary issue.  The stack trace
+ captionLine3=is reported below.  If you wish to submit a bug report, please
+ captionLine4=use the Save button to create a more detailed error log.
+ exceptionString={0}: {1}
+ saveButton=&Save Report
+ closeButton=\ \ \ &Close\ \ \ 
+ errorDialogTitle=Error
+ errorSavingFile=Error saving file: {0}
diff --combined src/jexer/TField.java
index 0000000000000000000000000000000000000000,7c8b5bc415e62882a24941734da6ac213c706b75..7c8b5bc415e62882a24941734da6ac213c706b75
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,671 +1,671 @@@
+ /*
+  * Jexer - Java Text User Interface
+  *
+  * The MIT License (MIT)
+  *
+  * 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"),
+  * to deal in the Software without restriction, including without limitation
+  * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+  * and/or sell copies of the Software, and to permit persons to whom the
+  * Software is furnished to do so, subject to the following conditions:
+  *
+  * The above copyright notice and this permission notice shall be included in
+  * all copies or substantial portions of the Software.
+  *
+  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+  * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+  * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+  * DEALINGS IN THE SOFTWARE.
+  *
+  * @author Kevin Lamonte [kevin.lamonte@gmail.com]
+  * @version 1
+  */
+ package jexer;
+ import jexer.bits.CellAttributes;
+ import jexer.bits.GraphicsChars;
+ import jexer.bits.StringUtils;
+ import jexer.event.TKeypressEvent;
+ import jexer.event.TMouseEvent;
+ import static jexer.TKeypress.*;
+ /**
+  * TField implements an editable text field.
+  */
+ public class TField extends TWidget {
+     // ------------------------------------------------------------------------
+     // Variables --------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Background character for unfilled-in text.
+      */
+     protected int backgroundChar = GraphicsChars.HATCH;
+     /**
+      * Field text.
+      */
+     protected String text = "";
+     /**
+      * If true, only allow enough characters that will fit in the width.  If
+      * false, allow the field to scroll to the right.
+      */
+     protected boolean fixed = false;
+     /**
+      * Current editing position within text.
+      */
+     protected int position = 0;
+     /**
+      * Current editing position screen column number.
+      */
+     protected int screenPosition = 0;
+     /**
+      * Beginning of visible portion.
+      */
+     protected int windowStart = 0;
+     /**
+      * If true, new characters are inserted at position.
+      */
+     protected boolean insertMode = true;
+     /**
+      * Remember mouse state.
+      */
+     protected TMouseEvent mouse;
+     /**
+      * The action to perform when the user presses enter.
+      */
+     protected TAction enterAction;
+     /**
+      * The action to perform when the text is updated.
+      */
+     protected TAction updateAction;
+     /**
+      * The color to use when this field is active.
+      */
+     private String activeColorKey = "tfield.active";
+     /**
+      * The color to use when this field is not active.
+      */
+     private String inactiveColorKey = "tfield.inactive";
+     // ------------------------------------------------------------------------
+     // Constructors -----------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Public constructor.
+      *
+      * @param parent parent widget
+      * @param x column relative to parent
+      * @param y row relative to parent
+      * @param width visible text width
+      * @param fixed if true, the text cannot exceed the display width
+      */
+     public TField(final TWidget parent, final int x, final int y,
+         final int width, final boolean fixed) {
+         this(parent, x, y, width, fixed, "", null, null);
+     }
+     /**
+      * Public constructor.
+      *
+      * @param parent parent widget
+      * @param x column relative to parent
+      * @param y row relative to parent
+      * @param width visible text width
+      * @param fixed if true, the text cannot exceed the display width
+      * @param text initial text, default is empty string
+      */
+     public TField(final TWidget parent, final int x, final int y,
+         final int width, final boolean fixed, final String text) {
+         this(parent, x, y, width, fixed, text, null, null);
+     }
+     /**
+      * Public constructor.
+      *
+      * @param parent parent widget
+      * @param x column relative to parent
+      * @param y row relative to parent
+      * @param width visible text width
+      * @param fixed if true, the text cannot exceed the display width
+      * @param text initial text, default is empty string
+      * @param enterAction function to call when enter key is pressed
+      * @param updateAction function to call when the text is updated
+      */
+     public TField(final TWidget parent, final int x, final int y,
+         final int width, final boolean fixed, final String text,
+         final TAction enterAction, final TAction updateAction) {
+         // Set parent and window
+         super(parent, x, y, width, 1);
+         setCursorVisible(true);
+         this.fixed = fixed;
+         this.text = text;
+         this.enterAction = enterAction;
+         this.updateAction = updateAction;
+     }
+     // ------------------------------------------------------------------------
+     // Event handlers ---------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Returns true if the mouse is currently on the field.
+      *
+      * @return if true the mouse is currently on the field
+      */
+     protected boolean mouseOnField() {
+         int rightEdge = getWidth() - 1;
+         if ((mouse != null)
+             && (mouse.getY() == 0)
+             && (mouse.getX() >= 0)
+             && (mouse.getX() <= rightEdge)
+         ) {
+             return true;
+         }
+         return false;
+     }
+     /**
+      * Handle mouse button presses.
+      *
+      * @param mouse mouse button event
+      */
+     @Override
+     public void onMouseDown(final TMouseEvent mouse) {
+         this.mouse = mouse;
+         if ((mouseOnField()) && (mouse.isMouse1())) {
+             // Move cursor
+             int deltaX = mouse.getX() - getCursorX();
+             screenPosition += deltaX;
+             if (screenPosition > StringUtils.width(text)) {
+                 screenPosition = StringUtils.width(text);
+             }
+             position = screenToTextPosition(screenPosition);
+             updateCursor();
+             return;
+         }
+     }
+     /**
+      * Handle keystrokes.
+      *
+      * @param keypress keystroke event
+      */
+     @Override
+     public void onKeypress(final TKeypressEvent keypress) {
+         if (keypress.equals(kbLeft)) {
+             if (position > 0) {
+                 screenPosition -= StringUtils.width(text.codePointBefore(position));
+                 position -= Character.charCount(text.codePointBefore(position));
+             }
+             if (fixed == false) {
+                 if ((screenPosition == windowStart) && (windowStart > 0)) {
+                     windowStart -= StringUtils.width(text.codePointAt(
+                         screenToTextPosition(windowStart)));
+                 }
+             }
+             normalizeWindowStart();
+             return;
+         }
+         if (keypress.equals(kbRight)) {
+             if (position < text.length()) {
+                 screenPosition += StringUtils.width(text.codePointAt(position));
+                 position += Character.charCount(text.codePointAt(position));
+                 if (fixed == true) {
+                     if (screenPosition == getWidth()) {
+                         screenPosition--;
+                         position -= Character.charCount(text.codePointAt(position));
+                     }
+                 } else {
+                     while ((screenPosition - windowStart +
+                             StringUtils.width(text.codePointAt(text.length() - 1)))
+                         > getWidth()
+                     ) {
+                         windowStart += StringUtils.width(text.codePointAt(
+                             screenToTextPosition(windowStart)));
+                     }
+                 }
+             }
+             assert (position <= text.length());
+             return;
+         }
+         if (keypress.equals(kbEnter)) {
+             dispatch(true);
+             return;
+         }
+         if (keypress.equals(kbIns)) {
+             insertMode = !insertMode;
+             return;
+         }
+         if (keypress.equals(kbHome)) {
+             home();
+             return;
+         }
+         if (keypress.equals(kbEnd)) {
+             end();
+             return;
+         }
+         if (keypress.equals(kbDel)) {
+             if ((text.length() > 0) && (position < text.length())) {
+                 text = text.substring(0, position)
+                         + text.substring(position + 1);
+                 screenPosition = StringUtils.width(text.substring(0, position));
+             }
+             dispatch(false);
+             return;
+         }
+         if (keypress.equals(kbBackspace) || keypress.equals(kbBackspaceDel)) {
+             if (position > 0) {
+                 position -= Character.charCount(text.codePointBefore(position));
+                 text = text.substring(0, position)
+                         + text.substring(position + 1);
+                 screenPosition = StringUtils.width(text.substring(0, position));
+             }
+             if (fixed == false) {
+                 if ((screenPosition >= windowStart)
+                     && (windowStart > 0)
+                 ) {
+                     windowStart -= StringUtils.width(text.codePointAt(
+                         screenToTextPosition(windowStart)));
+                 }
+             }
+             dispatch(false);
+             normalizeWindowStart();
+             return;
+         }
+         if (!keypress.getKey().isFnKey()
+             && !keypress.getKey().isAlt()
+             && !keypress.getKey().isCtrl()
+         ) {
+             // Plain old keystroke, process it
+             if ((position == text.length())
+                 && (StringUtils.width(text) < getWidth())) {
+                 // Append case
+                 appendChar(keypress.getKey().getChar());
+             } else if ((position < text.length())
+                 && (StringUtils.width(text) < getWidth())) {
+                 // Overwrite or insert a character
+                 if (insertMode == false) {
+                     // Replace character
+                     text = text.substring(0, position)
+                             + codePointString(keypress.getKey().getChar())
+                             + text.substring(position + 1);
+                     screenPosition += StringUtils.width(text.codePointAt(position));
+                     position += Character.charCount(keypress.getKey().getChar());
+                 } else {
+                     // Insert character
+                     insertChar(keypress.getKey().getChar());
+                 }
+             } else if ((position < text.length())
+                 && (StringUtils.width(text) >= getWidth())) {
+                 // Multiple cases here
+                 if ((fixed == true) && (insertMode == true)) {
+                     // Buffer is full, do nothing
+                 } else if ((fixed == true) && (insertMode == false)) {
+                     // Overwrite the last character, maybe move position
+                     text = text.substring(0, position)
+                             + codePointString(keypress.getKey().getChar())
+                             + text.substring(position + 1);
+                     if (screenPosition < getWidth() - 1) {
+                         screenPosition += StringUtils.width(text.codePointAt(position));
+                         position += Character.charCount(keypress.getKey().getChar());
+                     }
+                 } else if ((fixed == false) && (insertMode == false)) {
+                     // Overwrite the last character, definitely move position
+                     text = text.substring(0, position)
+                             + codePointString(keypress.getKey().getChar())
+                             + text.substring(position + 1);
+                     screenPosition += StringUtils.width(text.codePointAt(position));
+                     position += Character.charCount(keypress.getKey().getChar());
+                 } else {
+                     if (position == text.length()) {
+                         // Append this character
+                         appendChar(keypress.getKey().getChar());
+                     } else {
+                         // Insert this character
+                         insertChar(keypress.getKey().getChar());
+                     }
+                 }
+             } else {
+                 assert (!fixed);
+                 // Append this character
+                 appendChar(keypress.getKey().getChar());
+             }
+             dispatch(false);
+             return;
+         }
+         // Pass to parent for the things we don't care about.
+         super.onKeypress(keypress);
+     }
+     // ------------------------------------------------------------------------
+     // TWidget ----------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Override TWidget's height: we can only set height at construction
+      * time.
+      *
+      * @param height new widget height (ignored)
+      */
+     @Override
+     public void setHeight(final int height) {
+         // Do nothing
+     }
+     /**
+      * Draw the text field.
+      */
+     @Override
+     public void draw() {
+         CellAttributes fieldColor;
+         if (isAbsoluteActive()) {
+             fieldColor = getTheme().getColor(activeColorKey);
+         } else {
+             fieldColor = getTheme().getColor(inactiveColorKey);
+         }
+         int end = windowStart + getWidth();
+         if (end > StringUtils.width(text)) {
+             end = StringUtils.width(text);
+         }
+         hLineXY(0, 0, getWidth(), backgroundChar, fieldColor);
+         putStringXY(0, 0, text.substring(screenToTextPosition(windowStart),
+                 screenToTextPosition(end)), fieldColor);
+         // Fix the cursor, it will be rendered by TApplication.drawAll().
+         updateCursor();
+     }
+     // ------------------------------------------------------------------------
+     // TField -----------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Convert a char (codepoint) to a string.
+      *
+      * @param ch the char
+      * @return the string
+      */
+     private String codePointString(final int ch) {
+         StringBuilder sb = new StringBuilder(1);
+         sb.append(Character.toChars(ch));
+         assert (Character.charCount(ch) == sb.length());
+         return sb.toString();
+     }
+     /**
+      * Get field background character.
+      *
+      * @return background character
+      */
+     public final int getBackgroundChar() {
+         return backgroundChar;
+     }
+     /**
+      * Set field background character.
+      *
+      * @param backgroundChar the background character
+      */
+     public void setBackgroundChar(final int backgroundChar) {
+         this.backgroundChar = backgroundChar;
+     }
+     /**
+      * Get field text.
+      *
+      * @return field text
+      */
+     public final String getText() {
+         return text;
+     }
+     /**
+      * Set field text.
+      *
+      * @param text the new field text
+      */
+     public void setText(final String text) {
+         assert (text != null);
+         this.text = text;
+         position = 0;
+         windowStart = 0;
+     }
+     /**
+      * Dispatch to the action function.
+      *
+      * @param enter if true, the user pressed Enter, else this was an update
+      * to the text.
+      */
+     protected void dispatch(final boolean enter) {
+         if (enter) {
+             if (enterAction != null) {
+                 enterAction.DO(this);
+             }
+         } else {
+             if (updateAction != null) {
+                 updateAction.DO(this);
+             }
+         }
+     }
+     /**
+      * Determine string position from screen position.
+      *
+      * @param screenPosition the position on screen
+      * @return the equivalent position in text
+      */
+     protected int screenToTextPosition(final int screenPosition) {
+         if (screenPosition == 0) {
+             return 0;
+         }
+         int n = 0;
+         for (int i = 0; i < text.length(); i++) {
+             n += StringUtils.width(text.codePointAt(i));
+             if (n >= screenPosition) {
+                 return i + 1;
+             }
+         }
+         // screenPosition exceeds the available text length.
+         throw new IndexOutOfBoundsException("screenPosition " + screenPosition +
+             " exceeds available text length " + text.length());
+     }
+     /**
+      * Update the visible cursor position to match the location of position
+      * and windowStart.
+      */
+     protected void updateCursor() {
+         if ((screenPosition > getWidth()) && fixed) {
+             setCursorX(getWidth());
+         } else if ((screenPosition - windowStart >= getWidth()) && !fixed) {
+             setCursorX(getWidth() - 1);
+         } else {
+             setCursorX(screenPosition - windowStart);
+         }
+     }
+     /**
+      * Normalize windowStart such that most of the field data if visible.
+      */
+     protected void normalizeWindowStart() {
+         if (fixed) {
+             // windowStart had better be zero, there is nothing to do here.
+             assert (windowStart == 0);
+             return;
+         }
+         windowStart = screenPosition - (getWidth() - 1);
+         if (windowStart < 0) {
+             windowStart = 0;
+         }
+         updateCursor();
+     }
+     /**
+      * Append char to the end of the field.
+      *
+      * @param ch char to append
+      */
+     protected void appendChar(final int ch) {
+         // Append the LAST character
+         text += codePointString(ch);
+         position += Character.charCount(ch);
+         screenPosition += StringUtils.width(ch);
+         assert (position == text.length());
+         if (fixed) {
+             if (screenPosition >= getWidth()) {
+                 position -= Character.charCount(ch);
+                 screenPosition -= StringUtils.width(ch);
+             }
+         } else {
+             if ((screenPosition - windowStart) >= getWidth()) {
+                 windowStart++;
+             }
+         }
+     }
+     /**
+      * Insert char somewhere in the middle of the field.
+      *
+      * @param ch char to append
+      */
+     protected void insertChar(final int ch) {
+         text = text.substring(0, position) + codePointString(ch)
+                 + text.substring(position);
+         position += Character.charCount(ch);
+         screenPosition += StringUtils.width(ch);
+         if ((screenPosition - windowStart) == getWidth()) {
+             assert (!fixed);
+             windowStart++;
+         }
+     }
+     /**
+      * Position the cursor at the first column.  The field may adjust the
+      * window start to show as much of the field as possible.
+      */
+     public void home() {
+         position = 0;
+         screenPosition = 0;
+         windowStart = 0;
+     }
+     /**
+      * Set the editing position to the last filled character.  The field may
+      * adjust the window start to show as much of the field as possible.
+      */
+     public void end() {
+         position = text.length();
+         screenPosition = StringUtils.width(text);
+         if (fixed == true) {
+             if (screenPosition >= getWidth()) {
+                 position -= Character.charCount(text.codePointBefore(position));
+                 screenPosition = StringUtils.width(text) - 1;
+              }
+         } else {
+             windowStart = StringUtils.width(text) - getWidth() + 1;
+             if (windowStart < 0) {
+                 windowStart = 0;
+             }
+         }
+     }
+     /**
+      * Set the editing position.  The field may adjust the window start to
+      * show as much of the field as possible.
+      *
+      * @param position the new position
+      * @throws IndexOutOfBoundsException if position is outside the range of
+      * the available text
+      */
+     public void setPosition(final int position) {
+         if ((position < 0) || (position >= text.length())) {
+             throw new IndexOutOfBoundsException("Max length is " +
+                 text.length() + ", requested position " + position);
+         }
+         this.position = position;
+         normalizeWindowStart();
+     }
+     /**
+      * Set the active color key.
+      *
+      * @param activeColorKey ColorTheme key color to use when this field is
+      * active
+      */
+     public void setActiveColorKey(final String activeColorKey) {
+         this.activeColorKey = activeColorKey;
+     }
+     /**
+      * Set the inactive color key.
+      *
+      * @param inactiveColorKey ColorTheme key color to use when this field is
+      * inactive
+      */
+     public void setInactiveColorKey(final String inactiveColorKey) {
+         this.inactiveColorKey = inactiveColorKey;
+     }
+     /**
+      * Set the action to perform when the user presses enter.
+      *
+      * @param action the action to perform when the user presses enter
+      */
+     public void setEnterAction(final TAction action) {
+         enterAction = action;
+     }
+     /**
+      * Set the action to perform when the field is updated.
+      *
+      * @param action the action to perform when the field is updated
+      */
+     public void setUpdateAction(final TAction action) {
+         updateAction = action;
+     }
+ }
index 0000000000000000000000000000000000000000,a2cc0cf7e48f2e68efebfefec3c66d14775796c9..a2cc0cf7e48f2e68efebfefec3c66d14775796c9
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,416 +1,416 @@@
+ /*
+  * Jexer - Java Text User Interface
+  *
+  * The MIT License (MIT)
+  *
+  * 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"),
+  * to deal in the Software without restriction, including without limitation
+  * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+  * and/or sell copies of the Software, and to permit persons to whom the
+  * Software is furnished to do so, subject to the following conditions:
+  *
+  * The above copyright notice and this permission notice shall be included in
+  * all copies or substantial portions of the Software.
+  *
+  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+  * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+  * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+  * DEALINGS IN THE SOFTWARE.
+  *
+  * @author Kevin Lamonte [kevin.lamonte@gmail.com]
+  * @version 1
+  */
+ package jexer;
+ import java.io.File;
+ import java.io.IOException;
+ import java.util.List;
+ import java.util.ResourceBundle;
+ import jexer.backend.SwingTerminal;
+ import jexer.bits.GraphicsChars;
+ import jexer.event.TKeypressEvent;
+ import jexer.ttree.TDirectoryTreeItem;
+ import jexer.ttree.TTreeItem;
+ import jexer.ttree.TTreeViewWidget;
+ import static jexer.TKeypress.*;
+ /**
+  * TFileOpenBox is a system-modal dialog for selecting a file to open.  Call
+  * it like:
+  *
+  * <pre>
+  * {@code
+  *     filename = fileOpenBox("/path/to/file.ext",
+  *         TFileOpenBox.Type.OPEN);
+  *     if (filename != null) {
+  *         ... the user selected a file, go open it ...
+  *     }
+  * }
+  * </pre>
+  *
+  */
+ public class TFileOpenBox extends TWindow {
+     /**
+      * Translated strings.
+      */
+     private static final ResourceBundle i18n = ResourceBundle.getBundle(TFileOpenBox.class.getName());
+     // ------------------------------------------------------------------------
+     // Constants --------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * TFileOpenBox can be called for either Open or Save actions.
+      */
+     public enum Type {
+         /**
+          * Button will be labeled "Open".
+          */
+         OPEN,
+         /**
+          * Button will be labeled "Save".
+          */
+         SAVE,
+         /**
+          * Button will be labeled "Select".
+          */
+         SELECT
+     }
+     // ------------------------------------------------------------------------
+     // Variables --------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * String to return, or null if the user canceled.
+      */
+     private String filename = null;
+     /**
+      * The left-side tree view pane.
+      */
+     private TTreeViewWidget treeView;
+     /**
+      * The data behind treeView.
+      */
+     private TDirectoryTreeItem treeViewRoot;
+     /**
+      * The right-side directory list pane.
+      */
+     private TDirectoryList directoryList;
+     /**
+      * The top row text field.
+      */
+     private TField entryField;
+     /**
+      * The Open or Save button.
+      */
+     private TButton openButton;
+     /**
+      * The type of box this is (OPEN, SAVE, or SELECT).
+      */
+     private Type type = Type.OPEN;
+     // ------------------------------------------------------------------------
+     // Constructors -----------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Public constructor.  The file open box will be centered on screen.
+      *
+      * @param application the TApplication that manages this window
+      * @param path path of selected file
+      * @param type one of the Type constants
+      * @throws IOException of a java.io operation throws
+      */
+     public TFileOpenBox(final TApplication application, final String path,
+         final Type type) throws IOException {
+         this(application, path, type, null);
+     }
+     /**
+      * Public constructor.  The file open box will be centered on screen.
+      *
+      * @param application the TApplication that manages this window
+      * @param path path of selected file
+      * @param type one of the Type constants
+      * @param filters a list of strings that files must match to be displayed
+      * @throws IOException of a java.io operation throws
+      */
+     public TFileOpenBox(final TApplication application, final String path,
+         final Type type, final List<String> filters) throws IOException {
+         // Register with the TApplication
+         super(application, "", 0, 0, 76, 22, MODAL);
+         // Add text field
+         entryField = addField(1, 1, getWidth() - 4, false,
+             (new File(path)).getCanonicalPath(),
+             new TAction() {
+                 public void DO() {
+                     try {
+                         checkFilename(entryField.getText());
+                     } catch (IOException e) {
+                         // If the backend is Swing, we can emit the stack
+                         // trace to stderr.  Otherwise, just squash it.
+                         if (getScreen() instanceof SwingTerminal) {
+                             e.printStackTrace();
+                         }
+                     }
+                 }
+             }, null);
+         entryField.onKeypress(new TKeypressEvent(kbEnd));
+         // Add directory treeView
+         treeView = addTreeViewWidget(1, 3, 30, getHeight() - 6,
+             new TAction() {
+                 public void DO() {
+                     TTreeItem item = treeView.getSelected();
+                     File selectedDir = ((TDirectoryTreeItem) item).getFile();
+                     try {
+                         directoryList.setPath(selectedDir.getCanonicalPath());
+                         entryField.setText(selectedDir.getCanonicalPath());
+                         if (type == Type.OPEN) {
+                             openButton.setEnabled(false);
+                         }
+                         activate(treeView);
+                     } catch (IOException e) {
+                         // If the backend is Swing, we can emit the stack
+                         // trace to stderr.  Otherwise, just squash it.
+                         if (getScreen() instanceof SwingTerminal) {
+                             e.printStackTrace();
+                         }
+                     }
+                 }
+             }
+         );
+         treeViewRoot = new TDirectoryTreeItem(treeView, path, true);
+         // Add directory files list
+         directoryList = addDirectoryList(path, 34, 3, 28, getHeight() - 6,
+             new TAction() {
+                 public void DO() {
+                     try {
+                         File newPath = directoryList.getPath();
+                         entryField.setText(newPath.getCanonicalPath());
+                         entryField.onKeypress(new TKeypressEvent(kbEnd));
+                         openButton.setEnabled(true);
+                         activate(entryField);
+                         checkFilename(entryField.getText());
+                     } catch (IOException e) {
+                         // If the backend is Swing, we can emit the stack
+                         // trace to stderr.  Otherwise, just squash it.
+                         if (getScreen() instanceof SwingTerminal) {
+                             e.printStackTrace();
+                         }
+                     }
+                 }
+             },
+             new TAction() {
+                 public void DO() {
+                     try {
+                         File newPath = directoryList.getPath();
+                         entryField.setText(newPath.getCanonicalPath());
+                         entryField.onKeypress(new TKeypressEvent(kbEnd));
+                         openButton.setEnabled(true);
+                         activate(entryField);
+                     } catch (IOException e) {
+                         // If the backend is Swing, we can emit the stack
+                         // trace to stderr.  Otherwise, just squash it.
+                         if (getScreen() instanceof SwingTerminal) {
+                             e.printStackTrace();
+                         }
+                     }
+                 }
+             },
+             filters);
+         String openLabel = "";
+         switch (type) {
+         case OPEN:
+             openLabel = i18n.getString("openButton");
+             setTitle(i18n.getString("openTitle"));
+             break;
+         case SAVE:
+             openLabel = i18n.getString("saveButton");
+             setTitle(i18n.getString("saveTitle"));
+             break;
+         case SELECT:
+             openLabel = i18n.getString("selectButton");
+             setTitle(i18n.getString("selectTitle"));
+             break;
+         default:
+             throw new IllegalArgumentException("Invalid type: " + type);
+         }
+         this.type = type;
+         // Setup button actions
+         openButton = addButton(openLabel, this.getWidth() - 12, 3,
+             new TAction() {
+                 public void DO() {
+                     try {
+                         checkFilename(entryField.getText());
+                     } catch (IOException e) {
+                         // If the backend is Swing, we can emit the stack
+                         // trace to stderr.  Otherwise, just squash it.
+                         if (getScreen() instanceof SwingTerminal) {
+                             e.printStackTrace();
+                         }
+                     }
+                 }
+             }
+         );
+         if (type == Type.OPEN) {
+             openButton.setEnabled(false);
+         }
+         addButton(i18n.getString("cancelButton"), getWidth() - 12, 5,
+             new TAction() {
+                 public void DO() {
+                     filename = null;
+                     getApplication().closeWindow(TFileOpenBox.this);
+                 }
+             }
+         );
+         // Default to the directory list
+         activate(directoryList);
+         // Set the secondaryFiber to run me
+         getApplication().enableSecondaryEventReceiver(this);
+         // Yield to the secondary thread.  When I come back from the
+         // constructor response will already be set.
+         getApplication().yield();
+     }
+     // ------------------------------------------------------------------------
+     // Event handlers ---------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Handle keystrokes.
+      *
+      * @param keypress keystroke event
+      */
+     @Override
+     public void onKeypress(final TKeypressEvent keypress) {
+         // Escape - behave like cancel
+         if (keypress.equals(kbEsc)) {
+             // Close window
+             filename = null;
+             getApplication().closeWindow(this);
+             return;
+         }
+         if (treeView.isActive()) {
+             if ((keypress.equals(kbEnter))
+                 || (keypress.equals(kbUp))
+                 || (keypress.equals(kbDown))
+                 || (keypress.equals(kbPgUp))
+                 || (keypress.equals(kbPgDn))
+                 || (keypress.equals(kbHome))
+                 || (keypress.equals(kbEnd))
+             ) {
+                 // Tree view will be changing, update the directory list.
+                 super.onKeypress(keypress);
+                 // This is the same action as treeView's enter.
+                 TTreeItem item = treeView.getSelected();
+                 File selectedDir = ((TDirectoryTreeItem) item).getFile();
+                 try {
+                     directoryList.setPath(selectedDir.getCanonicalPath());
+                     if (type == Type.OPEN) {
+                         openButton.setEnabled(false);
+                     }
+                     activate(treeView);
+                 } catch (IOException e) {
+                     // If the backend is Swing, we can emit the stack trace
+                     // to stderr.  Otherwise, just squash it.
+                     if (getScreen() instanceof SwingTerminal) {
+                         e.printStackTrace();
+                     }
+                 }
+                 return;
+             }
+         }
+         // Pass to my parent
+         super.onKeypress(keypress);
+     }
+     // ------------------------------------------------------------------------
+     // TWidget ----------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Draw me on screen.
+      */
+     @Override
+     public void draw() {
+         super.draw();
+         vLineXY(33, 4, getHeight() - 6, GraphicsChars.WINDOW_SIDE,
+             getBackground());
+     }
+     // ------------------------------------------------------------------------
+     // TFileOpenBox -----------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Get the return string.
+      *
+      * @return the filename the user selected, or null if they canceled.
+      */
+     public String getFilename() {
+         return filename;
+     }
+     /**
+      * See if there is a valid filename to return.  If the filename is a
+      * directory, then
+      *
+      * @param newFilename the filename to check and return
+      * @throws IOException of a java.io operation throws
+      */
+     private void checkFilename(final String newFilename) throws IOException {
+         File newFile = new File(newFilename);
+         if (newFile.exists()) {
+             if (newFile.isFile() || (type == Type.SELECT)) {
+                 filename = newFilename;
+                 getApplication().closeWindow(this);
+                 return;
+             }
+             if (newFile.isDirectory()) {
+                 treeViewRoot = new TDirectoryTreeItem(treeView,
+                     newFilename, true);
+                 treeView.setTreeRoot(treeViewRoot, true);
+                 if (type == Type.OPEN) {
+                     openButton.setEnabled(false);
+                 }
+                 directoryList.setPath(newFilename);
+             }
+         } else if (type != Type.OPEN) {
+             filename = newFilename;
+             getApplication().closeWindow(this);
+             return;
+         }
+     }
+ }
index 0000000000000000000000000000000000000000,ef40e8644a46d1a3e83cd82ab8792ccb8de7e455..ef40e8644a46d1a3e83cd82ab8792ccb8de7e455
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,7 +1,7 @@@
+ openButton=\ &Open\ 
+ openTitle=Open File...
+ saveButton=\ &Save\ 
+ saveTitle=Save File...
+ cancelButton=&Cancel
+ selectButton=S&elect
+ selectTitle=Select File...
index 0000000000000000000000000000000000000000,62eabb632d4841acd354bb5e0901a8e4ff4a8a93..62eabb632d4841acd354bb5e0901a8e4ff4a8a93
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,628 +1,628 @@@
+ /*
+  * Jexer - Java Text User Interface
+  *
+  * The MIT License (MIT)
+  *
+  * 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"),
+  * to deal in the Software without restriction, including without limitation
+  * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+  * and/or sell copies of the Software, and to permit persons to whom the
+  * Software is furnished to do so, subject to the following conditions:
+  *
+  * The above copyright notice and this permission notice shall be included in
+  * all copies or substantial portions of the Software.
+  *
+  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+  * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+  * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+  * DEALINGS IN THE SOFTWARE.
+  *
+  * @author Kevin Lamonte [kevin.lamonte@gmail.com]
+  * @version 1
+  */
+ package jexer;
+ import java.awt.Font;
+ import java.awt.GraphicsEnvironment;
+ import java.util.ArrayList;
+ import java.util.Arrays;
+ import java.util.List;
+ import java.util.ResourceBundle;
+ import jexer.backend.ECMA48Terminal;
+ import jexer.backend.SwingTerminal;
+ import jexer.bits.CellAttributes;
+ import jexer.bits.GraphicsChars;
+ import jexer.event.TKeypressEvent;
+ import static jexer.TKeypress.*;
+ /**
+  * TFontChooserWindow provides an easy UI for users to alter the running
+  * font.
+  *
+  */
+ public class TFontChooserWindow extends TWindow {
+     /**
+      * Translated strings.
+      */
+     private static final ResourceBundle i18n = ResourceBundle.getBundle(TFontChooserWindow.class.getName());
+     // ------------------------------------------------------------------------
+     // Variables --------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * The Swing screen.
+      */
+     private SwingTerminal terminal = null;
+     /**
+      * The ECMA48 screen.
+      */
+     private ECMA48Terminal ecmaTerminal = null;
+     /**
+      * The font name.
+      */
+     private TComboBox fontName;
+     /**
+      * The font size.
+      */
+     private TField fontSize;
+     /**
+      * The X text adjustment.
+      */
+     private TField textAdjustX;
+     /**
+      * The Y text adjustment.
+      */
+     private TField textAdjustY;
+     /**
+      * The height text adjustment.
+      */
+     private TField textAdjustHeight;
+     /**
+      * The width text adjustment.
+      */
+     private TField textAdjustWidth;
+     /**
+      * The sixel palette size.
+      */
+     private TComboBox sixelPaletteSize;
+     /**
+      * The original font size.
+      */
+     private int oldFontSize = 20;
+     /**
+      * The original font.
+      */
+     private Font oldFont = null;
+     /**
+      * The original text adjust X value.
+      */
+     private int oldTextAdjustX = 0;
+     /**
+      * The original text adjust Y value.
+      */
+     private int oldTextAdjustY = 0;
+     /**
+      * The original text adjust height value.
+      */
+     private int oldTextAdjustHeight = 0;
+     /**
+      * The original text adjust width value.
+      */
+     private int oldTextAdjustWidth = 0;
+     /**
+      * The original sixel palette (number of colors) value.
+      */
+     private int oldSixelPaletteSize = 1024;
+     // ------------------------------------------------------------------------
+     // Constructors -----------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Public constructor.  The window will be centered on screen.
+      *
+      * @param application the TApplication that manages this window
+      */
+     public TFontChooserWindow(final TApplication application) {
+         // Register with the TApplication
+         super(application, i18n.getString("windowTitle"), 0, 0, 60, 21, MODAL);
+         // Add shortcut text
+         newStatusBar(i18n.getString("statusBar"));
+         if (getScreen() instanceof SwingTerminal) {
+             terminal = (SwingTerminal) getScreen();
+         }
+         if (getScreen() instanceof ECMA48Terminal) {
+             ecmaTerminal = (ECMA48Terminal) getScreen();
+         }
+         addLabel(i18n.getString("fontName"), 1, 1, "ttext", false);
+         addLabel(i18n.getString("fontSize"), 1, 2, "ttext", false);
+         addLabel(i18n.getString("textAdjustX"), 1, 4, "ttext", false);
+         addLabel(i18n.getString("textAdjustY"), 1, 5, "ttext", false);
+         addLabel(i18n.getString("textAdjustHeight"), 1, 6, "ttext", false);
+         addLabel(i18n.getString("textAdjustWidth"), 1, 7, "ttext", false);
+         addLabel(i18n.getString("sixelPaletteSize"), 1, 9, "ttext", false);
+         int col = 21;
+         if (terminal == null) {
+             // Non-Swing case: we can't change anything
+             addLabel(i18n.getString("unavailable"), col, 1);
+             addLabel(i18n.getString("unavailable"), col, 2);
+             addLabel(i18n.getString("unavailable"), col, 4);
+             addLabel(i18n.getString("unavailable"), col, 5);
+             addLabel(i18n.getString("unavailable"), col, 6);
+             addLabel(i18n.getString("unavailable"), col, 7);
+         }
+         if (ecmaTerminal == null) {
+             addLabel(i18n.getString("unavailable"), col, 9);
+         }
+         if (ecmaTerminal != null) {
+             oldSixelPaletteSize = ecmaTerminal.getSixelPaletteSize();
+             String [] sixelSizes = { "2", "256", "512", "1024", "2048" };
+             List<String> sizes = new ArrayList<String>();
+             sizes.addAll(Arrays.asList(sixelSizes));
+             sixelPaletteSize = addComboBox(col, 9, 10, sizes, 0, 6,
+                 new TAction() {
+                     public void DO() {
+                         try {
+                             ecmaTerminal.setSixelPaletteSize(Integer.parseInt(
+                                 sixelPaletteSize.getText()));
+                         } catch (NumberFormatException e) {
+                             // SQUASH
+                         }
+                     }
+                 }
+             );
+             sixelPaletteSize.setText(Integer.toString(oldSixelPaletteSize));
+         }
+         if (terminal != null) {
+             oldFont = terminal.getFont();
+             oldFontSize = terminal.getFontSize();
+             oldTextAdjustX = terminal.getTextAdjustX();
+             oldTextAdjustY = terminal.getTextAdjustY();
+             oldTextAdjustHeight = terminal.getTextAdjustHeight();
+             oldTextAdjustWidth = terminal.getTextAdjustWidth();
+             String [] fontNames = GraphicsEnvironment.
+                 getLocalGraphicsEnvironment().getAvailableFontFamilyNames();
+             List<String> fonts = new ArrayList<String>();
+             fonts.add(0, i18n.getString("builtInTerminus"));
+             fonts.addAll(Arrays.asList(fontNames));
+             fontName = addComboBox(col, 1, 25, fonts, 0, 10,
+                 new TAction() {
+                     public void DO() {
+                         if (fontName.getText().equals(i18n.
+                                 getString("builtInTerminus"))) {
+                             terminal.setDefaultFont();
+                         } else {
+                             terminal.setFont(new Font(fontName.getText(),
+                                     Font.PLAIN, terminal.getFontSize()));
+                             fontSize.setText(Integer.toString(
+                                 terminal.getFontSize()));
+                             textAdjustX.setText(Integer.toString(
+                                 terminal.getTextAdjustX()));
+                             textAdjustY.setText(Integer.toString(
+                                 terminal.getTextAdjustY()));
+                             textAdjustHeight.setText(Integer.toString(
+                                 terminal.getTextAdjustHeight()));
+                             textAdjustWidth.setText(Integer.toString(
+                                 terminal.getTextAdjustWidth()));
+                         }
+                     }
+                 }
+             );
+             // Font size
+             fontSize = addField(col, 2, 3, true,
+                 Integer.toString(terminal.getFontSize()),
+                 new TAction() {
+                     public void DO() {
+                         int currentSize = terminal.getFontSize();
+                         int newSize = currentSize;
+                         try {
+                             newSize = Integer.parseInt(fontSize.getText());
+                         } catch (NumberFormatException e) {
+                             fontSize.setText(Integer.toString(currentSize));
+                         }
+                         if (newSize != currentSize) {
+                             terminal.setFontSize(newSize);
+                             textAdjustX.setText(Integer.toString(
+                                 terminal.getTextAdjustX()));
+                             textAdjustY.setText(Integer.toString(
+                                 terminal.getTextAdjustY()));
+                             textAdjustHeight.setText(Integer.toString(
+                                 terminal.getTextAdjustHeight()));
+                             textAdjustWidth.setText(Integer.toString(
+                                 terminal.getTextAdjustWidth()));
+                         }
+                     }
+                 },
+                 null);
+             addSpinner(col + 3, 2,
+                 new TAction() {
+                     public void DO() {
+                         int currentSize = terminal.getFontSize();
+                         int newSize = currentSize;
+                         try {
+                             newSize = Integer.parseInt(fontSize.getText());
+                             newSize++;
+                         } catch (NumberFormatException e) {
+                             fontSize.setText(Integer.toString(currentSize));
+                         }
+                         fontSize.setText(Integer.toString(newSize));
+                         if (newSize != currentSize) {
+                             terminal.setFontSize(newSize);
+                             textAdjustX.setText(Integer.toString(
+                                 terminal.getTextAdjustX()));
+                             textAdjustY.setText(Integer.toString(
+                                 terminal.getTextAdjustY()));
+                             textAdjustHeight.setText(Integer.toString(
+                                 terminal.getTextAdjustHeight()));
+                             textAdjustWidth.setText(Integer.toString(
+                                 terminal.getTextAdjustWidth()));
+                         }
+                     }
+                 },
+                 new TAction() {
+                     public void DO() {
+                         int currentSize = terminal.getFontSize();
+                         int newSize = currentSize;
+                         try {
+                             newSize = Integer.parseInt(fontSize.getText());
+                             newSize--;
+                         } catch (NumberFormatException e) {
+                             fontSize.setText(Integer.toString(currentSize));
+                         }
+                         fontSize.setText(Integer.toString(newSize));
+                         if (newSize != currentSize) {
+                             terminal.setFontSize(newSize);
+                             textAdjustX.setText(Integer.toString(
+                                 terminal.getTextAdjustX()));
+                             textAdjustY.setText(Integer.toString(
+                                 terminal.getTextAdjustY()));
+                             textAdjustHeight.setText(Integer.toString(
+                                 terminal.getTextAdjustHeight()));
+                             textAdjustWidth.setText(Integer.toString(
+                                 terminal.getTextAdjustWidth()));
+                         }
+                     }
+                 }
+             );
+             // textAdjustX
+             textAdjustX = addField(col, 4, 3, true,
+                 Integer.toString(terminal.getTextAdjustX()),
+                 new TAction() {
+                     public void DO() {
+                         int currentAdjust = terminal.getTextAdjustX();
+                         int newAdjust = currentAdjust;
+                         try {
+                             newAdjust = Integer.parseInt(textAdjustX.getText());
+                         } catch (NumberFormatException e) {
+                             textAdjustX.setText(Integer.toString(currentAdjust));
+                         }
+                         if (newAdjust != currentAdjust) {
+                             terminal.setTextAdjustX(newAdjust);
+                         }
+                     }
+                 },
+                 null);
+             addSpinner(col + 3, 4,
+                 new TAction() {
+                     public void DO() {
+                         int currentAdjust = terminal.getTextAdjustX();
+                         int newAdjust = currentAdjust;
+                         try {
+                             newAdjust = Integer.parseInt(textAdjustX.getText());
+                             newAdjust++;
+                         } catch (NumberFormatException e) {
+                             textAdjustX.setText(Integer.toString(currentAdjust));
+                         }
+                         textAdjustX.setText(Integer.toString(newAdjust));
+                         if (newAdjust != currentAdjust) {
+                             terminal.setTextAdjustX(newAdjust);
+                         }
+                     }
+                 },
+                 new TAction() {
+                     public void DO() {
+                         int currentAdjust = terminal.getTextAdjustX();
+                         int newAdjust = currentAdjust;
+                         try {
+                             newAdjust = Integer.parseInt(textAdjustX.getText());
+                             newAdjust--;
+                         } catch (NumberFormatException e) {
+                             textAdjustX.setText(Integer.toString(currentAdjust));
+                         }
+                         textAdjustX.setText(Integer.toString(newAdjust));
+                         if (newAdjust != currentAdjust) {
+                             terminal.setTextAdjustX(newAdjust);
+                         }
+                     }
+                 }
+             );
+             // textAdjustY
+             textAdjustY = addField(col, 5, 3, true,
+                 Integer.toString(terminal.getTextAdjustY()),
+                 new TAction() {
+                     public void DO() {
+                         int currentAdjust = terminal.getTextAdjustY();
+                         int newAdjust = currentAdjust;
+                         try {
+                             newAdjust = Integer.parseInt(textAdjustY.getText());
+                         } catch (NumberFormatException e) {
+                             textAdjustY.setText(Integer.toString(currentAdjust));
+                         }
+                         if (newAdjust != currentAdjust) {
+                             terminal.setTextAdjustY(newAdjust);
+                         }
+                     }
+                 },
+                 null);
+             addSpinner(col + 3, 5,
+                 new TAction() {
+                     public void DO() {
+                         int currentAdjust = terminal.getTextAdjustY();
+                         int newAdjust = currentAdjust;
+                         try {
+                             newAdjust = Integer.parseInt(textAdjustY.getText());
+                             newAdjust++;
+                         } catch (NumberFormatException e) {
+                             textAdjustY.setText(Integer.toString(currentAdjust));
+                         }
+                         textAdjustY.setText(Integer.toString(newAdjust));
+                         if (newAdjust != currentAdjust) {
+                             terminal.setTextAdjustY(newAdjust);
+                         }
+                     }
+                 },
+                 new TAction() {
+                     public void DO() {
+                         int currentAdjust = terminal.getTextAdjustY();
+                         int newAdjust = currentAdjust;
+                         try {
+                             newAdjust = Integer.parseInt(textAdjustY.getText());
+                             newAdjust--;
+                         } catch (NumberFormatException e) {
+                             textAdjustY.setText(Integer.toString(currentAdjust));
+                         }
+                         textAdjustY.setText(Integer.toString(newAdjust));
+                         if (newAdjust != currentAdjust) {
+                             terminal.setTextAdjustY(newAdjust);
+                         }
+                     }
+                 }
+             );
+             // textAdjustHeight
+             textAdjustHeight = addField(col, 6, 3, true,
+                 Integer.toString(terminal.getTextAdjustHeight()),
+                 new TAction() {
+                     public void DO() {
+                         int currentAdjust = terminal.getTextAdjustHeight();
+                         int newAdjust = currentAdjust;
+                         try {
+                             newAdjust = Integer.parseInt(textAdjustHeight.getText());
+                         } catch (NumberFormatException e) {
+                             textAdjustHeight.setText(Integer.toString(currentAdjust));
+                         }
+                         if (newAdjust != currentAdjust) {
+                             terminal.setTextAdjustHeight(newAdjust);
+                         }
+                     }
+                 },
+                 null);
+             addSpinner(col + 3, 6,
+                 new TAction() {
+                     public void DO() {
+                         int currentAdjust = terminal.getTextAdjustHeight();
+                         int newAdjust = currentAdjust;
+                         try {
+                             newAdjust = Integer.parseInt(textAdjustHeight.getText());
+                             newAdjust++;
+                         } catch (NumberFormatException e) {
+                             textAdjustHeight.setText(Integer.toString(currentAdjust));
+                         }
+                         textAdjustHeight.setText(Integer.toString(newAdjust));
+                         if (newAdjust != currentAdjust) {
+                             terminal.setTextAdjustHeight(newAdjust);
+                         }
+                     }
+                 },
+                 new TAction() {
+                     public void DO() {
+                         int currentAdjust = terminal.getTextAdjustHeight();
+                         int newAdjust = currentAdjust;
+                         try {
+                             newAdjust = Integer.parseInt(textAdjustHeight.getText());
+                             newAdjust--;
+                         } catch (NumberFormatException e) {
+                             textAdjustHeight.setText(Integer.toString(currentAdjust));
+                         }
+                         textAdjustHeight.setText(Integer.toString(newAdjust));
+                         if (newAdjust != currentAdjust) {
+                             terminal.setTextAdjustHeight(newAdjust);
+                         }
+                     }
+                 }
+             );
+             // textAdjustWidth
+             textAdjustWidth = addField(col, 7, 3, true,
+                 Integer.toString(terminal.getTextAdjustWidth()),
+                 new TAction() {
+                     public void DO() {
+                         int currentAdjust = terminal.getTextAdjustWidth();
+                         int newAdjust = currentAdjust;
+                         try {
+                             newAdjust = Integer.parseInt(textAdjustWidth.getText());
+                         } catch (NumberFormatException e) {
+                             textAdjustWidth.setText(Integer.toString(currentAdjust));
+                         }
+                         if (newAdjust != currentAdjust) {
+                             terminal.setTextAdjustWidth(newAdjust);
+                         }
+                     }
+                 },
+                 null);
+             addSpinner(col + 3, 7,
+                 new TAction() {
+                     public void DO() {
+                         int currentAdjust = terminal.getTextAdjustWidth();
+                         int newAdjust = currentAdjust;
+                         try {
+                             newAdjust = Integer.parseInt(textAdjustWidth.getText());
+                             newAdjust++;
+                         } catch (NumberFormatException e) {
+                             textAdjustWidth.setText(Integer.toString(currentAdjust));
+                         }
+                         textAdjustWidth.setText(Integer.toString(newAdjust));
+                         if (newAdjust != currentAdjust) {
+                             terminal.setTextAdjustWidth(newAdjust);
+                         }
+                     }
+                 },
+                 new TAction() {
+                     public void DO() {
+                         int currentAdjust = terminal.getTextAdjustWidth();
+                         int newAdjust = currentAdjust;
+                         try {
+                             newAdjust = Integer.parseInt(textAdjustWidth.getText());
+                             newAdjust--;
+                         } catch (NumberFormatException e) {
+                             textAdjustWidth.setText(Integer.toString(currentAdjust));
+                         }
+                         textAdjustWidth.setText(Integer.toString(newAdjust));
+                         if (newAdjust != currentAdjust) {
+                             terminal.setTextAdjustWidth(newAdjust);
+                         }
+                     }
+                 }
+             );
+         }
+         addButton(i18n.getString("okButton"), 18, getHeight() - 4,
+             new TAction() {
+                 public void DO() {
+                     // Close window.
+                     TFontChooserWindow.this.close();
+                 }
+             });
+         TButton cancelButton = addButton(i18n.getString("cancelButton"),
+             30, getHeight() - 4,
+             new TAction() {
+                 public void DO() {
+                     // Restore old values, then close the window.
+                     if (terminal != null) {
+                         terminal.setFont(oldFont);
+                         terminal.setFontSize(oldFontSize);
+                         terminal.setTextAdjustX(oldTextAdjustX);
+                         terminal.setTextAdjustY(oldTextAdjustY);
+                         terminal.setTextAdjustHeight(oldTextAdjustHeight);
+                         terminal.setTextAdjustWidth(oldTextAdjustWidth);
+                     }
+                     if (ecmaTerminal != null) {
+                         ecmaTerminal.setSixelPaletteSize(oldSixelPaletteSize);
+                     }
+                     TFontChooserWindow.this.close();
+                 }
+             });
+         // Save this for last: make the cancel button default action.
+         activate(cancelButton);
+     }
+     // ------------------------------------------------------------------------
+     // Event handlers ---------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Handle keystrokes.
+      *
+      * @param keypress keystroke event
+      */
+     @Override
+     public void onKeypress(final TKeypressEvent keypress) {
+         // Escape - behave like cancel
+         if (keypress.equals(kbEsc)) {
+             // Restore old values, then close the window.
+             if (terminal != null) {
+                 terminal.setFont(oldFont);
+                 terminal.setFontSize(oldFontSize);
+             }
+             if (ecmaTerminal != null) {
+                 ecmaTerminal.setSixelPaletteSize(oldSixelPaletteSize);
+             }
+             getApplication().closeWindow(this);
+             return;
+         }
+         // Pass to my parent
+         super.onKeypress(keypress);
+     }
+     // ------------------------------------------------------------------------
+     // TWindow ----------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Draw me on screen.
+      */
+     @Override
+     public void draw() {
+         super.draw();
+         int left = 34;
+         CellAttributes color = getTheme().getColor("ttext");
+         drawBox(left, 6, left + 24, 14, color, color, 3, false);
+         putStringXY(left + 2, 6, i18n.getString("sample"), color);
+         for (int i = 7; i < 13; i++) {
+             hLineXY(left + 1, i, 22, GraphicsChars.HATCH, color);
+         }
+     }
+     // ------------------------------------------------------------------------
+     // TFontChooserWindow -----------------------------------------------------
+     // ------------------------------------------------------------------------
+ }
index 0000000000000000000000000000000000000000,4ab274efd9a470f8980d271c02593835b6b23be0..4ab274efd9a470f8980d271c02593835b6b23be0
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,17 +1,17 @@@
+ windowTitle=Screen
+ okButton=\ \ &OK\ \ 
+ cancelButton=&Cancel
+ statusBar=Select Screen Options
+ fontName=Font name:
+ fontSize=Font size:
+ textAdjustX=X adjust:
+ textAdjustY=Y adjust:
+ textAdjustHeight=Height adjust:
+ textAdjustWidth=Width adjust:
+ sixelPaletteSize=Sixel Palette Size:
+ unavailable=Unavailable
+ builtInTerminus=Built-In Terminus
+ sample=\ Sample Window\ 
index 0000000000000000000000000000000000000000,a07bcd7a52b7636949e57948892d395cba41bcf9..a07bcd7a52b7636949e57948892d395cba41bcf9
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,407 +1,407 @@@
+ /*
+  * Jexer - Java Text User Interface
+  *
+  * The MIT License (MIT)
+  *
+  * 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"),
+  * to deal in the Software without restriction, including without limitation
+  * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+  * and/or sell copies of the Software, and to permit persons to whom the
+  * Software is furnished to do so, subject to the following conditions:
+  *
+  * The above copyright notice and this permission notice shall be included in
+  * all copies or substantial portions of the Software.
+  *
+  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+  * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+  * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+  * DEALINGS IN THE SOFTWARE.
+  *
+  * @author Kevin Lamonte [kevin.lamonte@gmail.com]
+  * @version 1
+  */
+ package jexer;
+ import jexer.bits.CellAttributes;
+ import jexer.bits.GraphicsChars;
+ import jexer.event.TMouseEvent;
+ /**
+  * THScroller implements a simple horizontal scroll bar.
+  */
+ public class THScroller extends TWidget {
+     // ------------------------------------------------------------------------
+     // Variables --------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Value that corresponds to being on the left edge of the scroll bar.
+      */
+     private int leftValue = 0;
+     /**
+      * Value that corresponds to being on the right edge of the scroll bar.
+      */
+     private int rightValue = 100;
+     /**
+      * Current value of the scroll.
+      */
+     private int value = 0;
+     /**
+      * The increment for clicking on an arrow.
+      */
+     private int smallChange = 1;
+     /**
+      * The increment for clicking in the bar between the box and an arrow.
+      */
+     private int bigChange = 20;
+     /**
+      * When true, the user is dragging the scroll box.
+      */
+     private boolean inScroll = false;
+     // ------------------------------------------------------------------------
+     // Constructors -----------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Public constructor.
+      *
+      * @param parent parent widget
+      * @param x column relative to parent
+      * @param y row relative to parent
+      * @param width height of scroll bar
+      */
+     public THScroller(final TWidget parent, final int x, final int y,
+         final int width) {
+         // Set parent and window
+         super(parent, x, y, width, 1);
+     }
+     // ------------------------------------------------------------------------
+     // Event handlers ---------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Handle mouse button releases.
+      *
+      * @param mouse mouse button release event
+      */
+     @Override
+     public void onMouseUp(final TMouseEvent mouse) {
+         if (inScroll) {
+             inScroll = false;
+             return;
+         }
+         if (rightValue == leftValue) {
+             return;
+         }
+         if ((mouse.getX() == 0)
+             && (mouse.getY() == 0)
+         ) {
+             // Clicked on the left arrow
+             decrement();
+             return;
+         }
+         if ((mouse.getY() == 0)
+             && (mouse.getX() == getWidth() - 1)
+         ) {
+             // Clicked on the right arrow
+             increment();
+             return;
+         }
+         if ((mouse.getY() == 0)
+             && (mouse.getX() > 0)
+             && (mouse.getX() < boxPosition())
+         ) {
+             // Clicked between the left arrow and the box
+             value -= bigChange;
+             if (value < leftValue) {
+                 value = leftValue;
+             }
+             return;
+         }
+         if ((mouse.getY() == 0)
+             && (mouse.getX() > boxPosition())
+             && (mouse.getX() < getWidth() - 1)
+         ) {
+             // Clicked between the box and the right arrow
+             value += bigChange;
+             if (value > rightValue) {
+                 value = rightValue;
+             }
+             return;
+         }
+     }
+     /**
+      * Handle mouse movement events.
+      *
+      * @param mouse mouse motion event
+      */
+     @Override
+     public void onMouseMotion(final TMouseEvent mouse) {
+         if (rightValue == leftValue) {
+             inScroll = false;
+             return;
+         }
+         if ((mouse.isMouse1())
+             && (inScroll)
+             && (mouse.getX() > 0)
+             && (mouse.getX() < getWidth() - 1)
+         ) {
+             // Recompute value based on new box position
+             value = (rightValue - leftValue)
+                 * (mouse.getX()) / (getWidth() - 3) + leftValue;
+             if (value > rightValue) {
+                 value = rightValue;
+             }
+             if (value < leftValue) {
+                 value = leftValue;
+             }
+             return;
+         }
+         inScroll = false;
+     }
+     /**
+      * Handle mouse button press events.
+      *
+      * @param mouse mouse button press event
+      */
+     @Override
+     public void onMouseDown(final TMouseEvent mouse) {
+         if (rightValue == leftValue) {
+             inScroll = false;
+             return;
+         }
+         if ((mouse.getY() == 0)
+             && (mouse.getX() == boxPosition())
+         ) {
+             inScroll = true;
+             return;
+         }
+     }
+     // ------------------------------------------------------------------------
+     // TWidget ----------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Draw a horizontal scroll bar.
+      */
+     @Override
+     public void draw() {
+         CellAttributes arrowColor = getTheme().getColor("tscroller.arrows");
+         CellAttributes barColor = getTheme().getColor("tscroller.bar");
+         putCharXY(0, 0, GraphicsChars.CP437[0x11], arrowColor);
+         putCharXY(getWidth() - 1, 0, GraphicsChars.CP437[0x10], arrowColor);
+         // Place the box
+         if (rightValue > leftValue) {
+             hLineXY(1, 0, getWidth() - 2, GraphicsChars.CP437[0xB1], barColor);
+             putCharXY(boxPosition(), 0, GraphicsChars.BOX, arrowColor);
+         } else {
+             hLineXY(1, 0, getWidth() - 2, GraphicsChars.HATCH, barColor);
+         }
+     }
+     // ------------------------------------------------------------------------
+     // THScroller -------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Get the value that corresponds to being on the left edge of the scroll
+      * bar.
+      *
+      * @return the scroll value
+      */
+     public int getLeftValue() {
+         return leftValue;
+     }
+     /**
+      * Set the value that corresponds to being on the left edge of the
+      * scroll bar.
+      *
+      * @param leftValue the new scroll value
+      */
+     public void setLeftValue(final int leftValue) {
+         this.leftValue = leftValue;
+     }
+     /**
+      * Get the value that corresponds to being on the right edge of the
+      * scroll bar.
+      *
+      * @return the scroll value
+      */
+     public int getRightValue() {
+         return rightValue;
+     }
+     /**
+      * Set the value that corresponds to being on the right edge of the
+      * scroll bar.
+      *
+      * @param rightValue the new scroll value
+      */
+     public void setRightValue(final int rightValue) {
+         this.rightValue = rightValue;
+     }
+     /**
+      * Get current value of the scroll.
+      *
+      * @return the scroll value
+      */
+     public int getValue() {
+         return value;
+     }
+     /**
+      * Set current value of the scroll.
+      *
+      * @param value the new scroll value
+      */
+     public void setValue(final int value) {
+         this.value = value;
+     }
+     /**
+      * Get the increment for clicking on an arrow.
+      *
+      * @return the increment value
+      */
+     public int getSmallChange() {
+         return smallChange;
+     }
+     /**
+      * Set the increment for clicking on an arrow.
+      *
+      * @param smallChange the new increment value
+      */
+     public void setSmallChange(final int smallChange) {
+         this.smallChange = smallChange;
+     }
+     /**
+      * Set the increment for clicking in the bar between the box and an
+      * arrow.
+      *
+      * @return the increment value
+      */
+     public int getBigChange() {
+         return bigChange;
+     }
+     /**
+      * Set the increment for clicking in the bar between the box and an
+      * arrow.
+      *
+      * @param bigChange the new increment value
+      */
+     public void setBigChange(final int bigChange) {
+         this.bigChange = bigChange;
+     }
+     /**
+      * Compute the position of the scroll box (a.k.a. grip, thumb).
+      *
+      * @return Y position of the box, between 1 and width - 2
+      */
+     private int boxPosition() {
+         return (getWidth() - 3) * (value - leftValue) / (rightValue - leftValue) + 1;
+     }
+     /**
+      * Perform a small step change left.
+      */
+     public void decrement() {
+         if (leftValue == rightValue) {
+             return;
+         }
+         value -= smallChange;
+         if (value < leftValue) {
+             value = leftValue;
+         }
+     }
+     /**
+      * Perform a small step change right.
+      */
+     public void increment() {
+         if (leftValue == rightValue) {
+             return;
+         }
+         value += smallChange;
+         if (value > rightValue) {
+             value = rightValue;
+         }
+     }
+     /**
+      * Perform a big step change left.
+      */
+     public void bigDecrement() {
+         if (leftValue == rightValue) {
+             return;
+         }
+         value -= bigChange;
+         if (value < leftValue) {
+             value = leftValue;
+         }
+     }
+     /**
+      * Perform a big step change right.
+      */
+     public void bigIncrement() {
+         if (rightValue == leftValue) {
+             return;
+         }
+         value += bigChange;
+         if (value > rightValue) {
+             value = rightValue;
+         }
+     }
+     /**
+      * Go to the left edge of the scroller.
+      */
+     public void toLeft() {
+         value = leftValue;
+     }
+     /**
+      * Go to the right edge of the scroller.
+      */
+     public void toRight() {
+         value = rightValue;
+     }
+ }
diff --combined src/jexer/TImage.java
index 0000000000000000000000000000000000000000,cd0ce96e0baf4523c64cb45527c01cbc4d1e1443..cd0ce96e0baf4523c64cb45527c01cbc4d1e1443
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,765 +1,765 @@@
+ /*
+  * Jexer - Java Text User Interface
+  *
+  * The MIT License (MIT)
+  *
+  * 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"),
+  * to deal in the Software without restriction, including without limitation
+  * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+  * and/or sell copies of the Software, and to permit persons to whom the
+  * Software is furnished to do so, subject to the following conditions:
+  *
+  * The above copyright notice and this permission notice shall be included in
+  * all copies or substantial portions of the Software.
+  *
+  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+  * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+  * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+  * DEALINGS IN THE SOFTWARE.
+  *
+  * @author Kevin Lamonte [kevin.lamonte@gmail.com]
+  * @version 1
+  */
+ package jexer;
+ import java.awt.image.BufferedImage;
+ import jexer.backend.ECMA48Terminal;
+ import jexer.backend.MultiScreen;
+ import jexer.backend.SwingTerminal;
+ import jexer.bits.Cell;
+ import jexer.event.TKeypressEvent;
+ import jexer.event.TMouseEvent;
+ import jexer.event.TResizeEvent;
+ import static jexer.TKeypress.*;
+ /**
+  * TImage renders a piece of a bitmap image on screen.
+  */
+ public class TImage extends TWidget {
+     // ------------------------------------------------------------------------
+     // Constants --------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Selections for fitting the image to the text cells.
+      */
+     public enum Scale {
+         /**
+          * No scaling.
+          */
+         NONE,
+         /**
+          * Stretch/shrink the image in both directions to fully fill the text
+          * area width/height.
+          */
+         STRETCH,
+         /**
+          * Scale the image, preserving aspect ratio, to fill the text area
+          * width/height (like letterbox).  The background color for the
+          * letterboxed area is specified in scaleBackColor.
+          */
+         SCALE,
+     }
+     // ------------------------------------------------------------------------
+     // Variables --------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Scaling strategy to use.
+      */
+     private Scale scale = Scale.NONE;
+     /**
+      * Scaling strategy to use.
+      */
+     private java.awt.Color scaleBackColor = java.awt.Color.BLACK;
+     /**
+      * The action to perform when the user clicks on the image.
+      */
+     private TAction clickAction;
+     /**
+      * The image to display.
+      */
+     private BufferedImage image;
+     /**
+      * The original image from construction time.
+      */
+     private BufferedImage originalImage;
+     /**
+      * The current scaling factor for the image.
+      */
+     private double scaleFactor = 1.0;
+     /**
+      * The current clockwise rotation for the image.
+      */
+     private int clockwise = 0;
+     /**
+      * If true, this widget was resized and a new scaled image must be
+      * produced.
+      */
+     private boolean resized = false;
+     /**
+      * Left column of the image.  0 is the left-most column.
+      */
+     private int left;
+     /**
+      * Top row of the image.  0 is the top-most row.
+      */
+     private int top;
+     /**
+      * The cells containing the broken up image pieces.
+      */
+     private Cell cells[][];
+     /**
+      * The number of rows in cells[].
+      */
+     private int cellRows;
+     /**
+      * The number of columns in cells[].
+      */
+     private int cellColumns;
+     /**
+      * Last text width value.
+      */
+     private int lastTextWidth = -1;
+     /**
+      * Last text height value.
+      */
+     private int lastTextHeight = -1;
+     // ------------------------------------------------------------------------
+     // Constructors -----------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Public constructor.
+      *
+      * @param parent parent widget
+      * @param x column relative to parent
+      * @param y row relative to parent
+      * @param width number of text cells for width of the image
+      * @param height number of text cells for height of the image
+      * @param image the image to display
+      * @param left left column of the image.  0 is the left-most column.
+      * @param top top row of the image.  0 is the top-most row.
+      */
+     public TImage(final TWidget parent, final int x, final int y,
+         final int width, final int height,
+         final BufferedImage image, final int left, final int top) {
+         this(parent, x, y, width, height, image, left, top, null);
+     }
+     /**
+      * Public constructor.
+      *
+      * @param parent parent widget
+      * @param x column relative to parent
+      * @param y row relative to parent
+      * @param width number of text cells for width of the image
+      * @param height number of text cells for height of the image
+      * @param image the image to display
+      * @param left left column of the image.  0 is the left-most column.
+      * @param top top row of the image.  0 is the top-most row.
+      * @param clickAction function to call when mouse is pressed
+      */
+     public TImage(final TWidget parent, final int x, final int y,
+         final int width, final int height,
+         final BufferedImage image, final int left, final int top,
+         final TAction clickAction) {
+         // Set parent and window
+         super(parent, x, y, width, height);
+         setCursorVisible(false);
+         this.originalImage = image;
+         this.left = left;
+         this.top = top;
+         this.clickAction = clickAction;
+         sizeToImage(true);
+     }
+     // ------------------------------------------------------------------------
+     // Event handlers ---------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Handle mouse press events.
+      *
+      * @param mouse mouse button press event
+      */
+     @Override
+     public void onMouseDown(final TMouseEvent mouse) {
+         if (clickAction != null) {
+             clickAction.DO(this);
+             return;
+         }
+     }
+     /**
+      * Handle keystrokes.
+      *
+      * @param keypress keystroke event
+      */
+     @Override
+     public void onKeypress(final TKeypressEvent keypress) {
+         if (!keypress.getKey().isFnKey()) {
+             if (keypress.getKey().getChar() == '+') {
+                 // Make the image bigger.
+                 scaleFactor *= 1.25;
+                 image = null;
+                 sizeToImage(true);
+                 return;
+             }
+             if (keypress.getKey().getChar() == '-') {
+                 // Make the image smaller.
+                 scaleFactor *= 0.80;
+                 image = null;
+                 sizeToImage(true);
+                 return;
+             }
+         }
+         if (keypress.equals(kbAltUp)) {
+             // Make the image bigger.
+             scaleFactor *= 1.25;
+             image = null;
+             sizeToImage(true);
+             return;
+         }
+         if (keypress.equals(kbAltDown)) {
+             // Make the image smaller.
+             scaleFactor *= 0.80;
+             image = null;
+             sizeToImage(true);
+             return;
+         }
+         if (keypress.equals(kbAltRight)) {
+             // Rotate clockwise.
+             clockwise++;
+             clockwise %= 4;
+             image = null;
+             sizeToImage(true);
+             return;
+         }
+         if (keypress.equals(kbAltLeft)) {
+             // Rotate counter-clockwise.
+             clockwise--;
+             if (clockwise < 0) {
+                 clockwise = 3;
+             }
+             image = null;
+             sizeToImage(true);
+             return;
+         }
+         if (keypress.equals(kbShiftLeft)) {
+             switch (scale) {
+             case NONE:
+                 setScaleType(Scale.SCALE);
+                 return;
+             case STRETCH:
+                 setScaleType(Scale.NONE);
+                 return;
+             case SCALE:
+                 setScaleType(Scale.STRETCH);
+                 return;
+             }
+         }
+         if (keypress.equals(kbShiftRight)) {
+             switch (scale) {
+             case NONE:
+                 setScaleType(Scale.STRETCH);
+                 return;
+             case STRETCH:
+                 setScaleType(Scale.SCALE);
+                 return;
+             case SCALE:
+                 setScaleType(Scale.NONE);
+                 return;
+             }
+         }
+         // Pass to parent for the things we don't care about.
+         super.onKeypress(keypress);
+     }
+     /**
+      * Handle resize events.
+      *
+      * @param event resize event
+      */
+     @Override
+     public void onResize(final TResizeEvent event) {
+         // Get my width/height set correctly.
+         super.onResize(event);
+         if (scale == Scale.NONE) {
+             return;
+         }
+         image = null;
+         resized = true;
+     }
+     // ------------------------------------------------------------------------
+     // TWidget ----------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Draw the image.
+      */
+     @Override
+     public void draw() {
+         sizeToImage(false);
+         // We have already broken the image up, just draw the last set of
+         // cells.
+         for (int x = 0; (x < getWidth()) && (x + left < cellColumns); x++) {
+             if ((left + x) * lastTextWidth > image.getWidth()) {
+                 continue;
+             }
+             for (int y = 0; (y < getHeight()) && (y + top < cellRows); y++) {
+                 if ((top + y) * lastTextHeight > image.getHeight()) {
+                     continue;
+                 }
+                 assert (x + left < cellColumns);
+                 assert (y + top < cellRows);
+                 getWindow().putCharXY(x, y, cells[x + left][y + top]);
+             }
+         }
+     }
+     // ------------------------------------------------------------------------
+     // TImage -----------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Size cells[][] according to the screen font size.
+      *
+      * @param always if true, always resize the cells
+      */
+     private void sizeToImage(final boolean always) {
+         int textWidth = getScreen().getTextWidth();
+         int textHeight = getScreen().getTextHeight();
+         if (image == null) {
+             image = rotateImage(originalImage, clockwise);
+             image = scaleImage(image, scaleFactor, getWidth(), getHeight(),
+                 textWidth, textHeight);
+         }
+         if ((always == true) ||
+             (resized == true) ||
+             ((textWidth > 0)
+                 && (textWidth != lastTextWidth)
+                 && (textHeight > 0)
+                 && (textHeight != lastTextHeight))
+         ) {
+             resized = false;
+             cellColumns = image.getWidth() / textWidth;
+             if (cellColumns * textWidth < image.getWidth()) {
+                 cellColumns++;
+             }
+             cellRows = image.getHeight() / textHeight;
+             if (cellRows * textHeight < image.getHeight()) {
+                 cellRows++;
+             }
+             // Break the image up into an array of cells.
+             cells = new Cell[cellColumns][cellRows];
+             for (int x = 0; x < cellColumns; x++) {
+                 for (int y = 0; y < cellRows; y++) {
+                     int width = textWidth;
+                     if ((x + 1) * textWidth > image.getWidth()) {
+                         width = image.getWidth() - (x * textWidth);
+                     }
+                     int height = textHeight;
+                     if ((y + 1) * textHeight > image.getHeight()) {
+                         height = image.getHeight() - (y * textHeight);
+                     }
+                     Cell cell = new Cell();
+                     cell.setImage(image.getSubimage(x * textWidth,
+                             y * textHeight, width, height));
+                     cells[x][y] = cell;
+                 }
+             }
+             lastTextWidth = textWidth;
+             lastTextHeight = textHeight;
+         }
+         if ((left + getWidth()) > cellColumns) {
+             left = cellColumns - getWidth();
+         }
+         if (left < 0) {
+             left = 0;
+         }
+         if ((top + getHeight()) > cellRows) {
+             top = cellRows - getHeight();
+         }
+         if (top < 0) {
+             top = 0;
+         }
+     }
+     /**
+      * Get the top corner to render.
+      *
+      * @return the top row
+      */
+     public int getTop() {
+         return top;
+     }
+     /**
+      * Set the top corner to render.
+      *
+      * @param top the new top row
+      */
+     public void setTop(final int top) {
+         this.top = top;
+         if (this.top > cellRows - getHeight()) {
+             this.top = cellRows - getHeight();
+         }
+         if (this.top < 0) {
+             this.top = 0;
+         }
+     }
+     /**
+      * Get the left corner to render.
+      *
+      * @return the left column
+      */
+     public int getLeft() {
+         return left;
+     }
+     /**
+      * Set the left corner to render.
+      *
+      * @param left the new left column
+      */
+     public void setLeft(final int left) {
+         this.left = left;
+         if (this.left > cellColumns - getWidth()) {
+             this.left = cellColumns - getWidth();
+         }
+         if (this.left < 0) {
+             this.left = 0;
+         }
+     }
+     /**
+      * Get the number of text cell rows for this image.
+      *
+      * @return the number of rows
+      */
+     public int getRows() {
+         return cellRows;
+     }
+     /**
+      * Get the number of text cell columns for this image.
+      *
+      * @return the number of columns
+      */
+     public int getColumns() {
+         return cellColumns;
+     }
+     /**
+      * Get the raw (unprocessed) image.
+      *
+      * @return the image
+      */
+     public BufferedImage getImage() {
+         return originalImage;
+     }
+     /**
+      * Set the raw image, and reprocess to make the visible image.
+      *
+      * @param image the new image
+      */
+     public void setImage(final BufferedImage image) {
+         this.originalImage = image;
+         this.image = null;
+         sizeToImage(true);
+     }
+     /**
+      * Get the visible (processed) image.
+      *
+      * @return the image that is currently on screen
+      */
+     public BufferedImage getVisibleImage() {
+         return image;
+     }
+     /**
+      * Get the scaling strategy.
+      *
+      * @return Scale.NONE, Scale.STRETCH, etc.
+      */
+     public Scale getScaleType() {
+         return scale;
+     }
+     /**
+      * Set the scaling strategy.
+      *
+      * @param scale Scale.NONE, Scale.STRETCH, etc.
+      */
+     public void setScaleType(final Scale scale) {
+         this.scale = scale;
+         this.image = null;
+         sizeToImage(true);
+     }
+     /**
+      * Get the scale factor.
+      *
+      * @return the scale factor
+      */
+     public double getScaleFactor() {
+         return scaleFactor;
+     }
+     /**
+      * Set the scale factor.  1.0 means no scaling.
+      *
+      * @param scaleFactor the new scale factor
+      */
+     public void setScaleFactor(final double scaleFactor) {
+         this.scaleFactor = scaleFactor;
+         image = null;
+         sizeToImage(true);
+     }
+     /**
+      * Get the rotation, as degrees.
+      *
+      * @return the rotation in degrees
+      */
+     public int getRotation() {
+         switch (clockwise) {
+         case 0:
+             return 0;
+         case 1:
+             return 90;
+         case 2:
+             return 180;
+         case 3:
+             return 270;
+         default:
+             // Don't know how this happened, but fix it.
+             clockwise = 0;
+             image = null;
+             sizeToImage(true);
+             return 0;
+         }
+     }
+     /**
+      * Set the rotation, as degrees clockwise.
+      *
+      * @param rotation 0, 90, 180, or 270
+      */
+     public void setRotation(final int rotation) {
+         switch (rotation) {
+         case 0:
+             clockwise = 0;
+             break;
+         case 90:
+             clockwise = 1;
+             break;
+         case 180:
+             clockwise = 2;
+             break;
+         case 270:
+             clockwise = 3;
+             break;
+         default:
+             // Don't know how this happened, but fix it.
+             clockwise = 0;
+             break;
+         }
+         image = null;
+         sizeToImage(true);
+     }
+     /**
+      * Scale an image by to be scaleFactor size.
+      *
+      * @param image the image to scale
+      * @param factor the scale to make the new image
+      * @param width the number of text cell columns for the destination image
+      * @param height the number of text cell rows for the destination image
+      * @param textWidth the width in pixels for one text cell
+      * @param textHeight the height in pixels for one text cell
+      */
+     private BufferedImage scaleImage(final BufferedImage image,
+         final double factor, final int width, final int height,
+         final int textWidth, final int textHeight) {
+         if ((scale == Scale.NONE) && (Math.abs(factor - 1.0) < 0.03)) {
+             // If we are within 3% of 1.0, just return the original image.
+             return image;
+         }
+         int destWidth = 0;
+         int destHeight = 0;
+         int x = 0;
+         int y = 0;
+         BufferedImage newImage = null;
+         switch (scale) {
+         case NONE:
+             destWidth = (int) (image.getWidth() * factor);
+             destHeight = (int) (image.getHeight() * factor);
+             newImage = new BufferedImage(destWidth, destHeight,
+                 BufferedImage.TYPE_INT_ARGB);
+             break;
+         case STRETCH:
+             destWidth = width * textWidth;
+             destHeight = height * textHeight;
+             newImage = new BufferedImage(destWidth, destHeight,
+                 BufferedImage.TYPE_INT_ARGB);
+             break;
+         case SCALE:
+             double a = (double) image.getWidth() / image.getHeight();
+             double b = (double) (width * textWidth) / (height * textHeight);
+             assert (a > 0);
+             assert (b > 0);
+             /*
+             System.err.println("Scale: original " + image.getWidth() +
+                 "x" + image.getHeight());
+             System.err.println("         screen " + (width * textWidth) +
+                 "x" + (height * textHeight));
+             System.err.println("A " + a + " B " + b);
+              */
+             if (a > b) {
+                 // Horizontal letterbox
+                 destWidth = width * textWidth;
+                 destHeight = (int) (destWidth / a);
+                 y = ((height * textHeight) - destHeight) / 2;
+                 assert (y >= 0);
+                 /*
+                 System.err.println("Horizontal letterbox: " + destWidth +
+                     "x" + destHeight + ", Y offset " + y);
+                  */
+             } else {
+                 // Vertical letterbox
+                 destHeight = height * textHeight;
+                 destWidth = (int) (destHeight * a);
+                 x = ((width * textWidth) - destWidth) / 2;
+                 assert (x >= 0);
+                 /*
+                 System.err.println("Vertical letterbox: " + destWidth +
+                     "x" + destHeight + ", X offset " + x);
+                  */
+             }
+             newImage = new BufferedImage(width * textWidth, height * textHeight,
+                 BufferedImage.TYPE_INT_ARGB);
+             break;
+         }
+         java.awt.Graphics gr = newImage.createGraphics();
+         if (scale == Scale.SCALE) {
+             gr.setColor(scaleBackColor);
+             gr.fillRect(0, 0, width * textWidth, height * textHeight);
+         }
+         gr.drawImage(image, x, y, destWidth, destHeight, null);
+         gr.dispose();
+         return newImage;
+     }
+     /**
+      * Rotate an image either clockwise or counterclockwise.
+      *
+      * @param image the image to scale
+      * @param clockwise number of turns clockwise
+      */
+     private BufferedImage rotateImage(final BufferedImage image,
+         final int clockwise) {
+         if (clockwise % 4 == 0) {
+             return image;
+         }
+         BufferedImage newImage = null;
+         if (clockwise % 4 == 1) {
+             // 90 degrees clockwise
+             newImage = new BufferedImage(image.getHeight(), image.getWidth(),
+                 BufferedImage.TYPE_INT_ARGB);
+             for (int x = 0; x < image.getWidth(); x++) {
+                 for (int y = 0; y < image.getHeight(); y++) {
+                     newImage.setRGB(y, x,
+                         image.getRGB(x, image.getHeight() - 1 - y));
+                 }
+             }
+         } else if (clockwise % 4 == 2) {
+             // 180 degrees clockwise
+             newImage = new BufferedImage(image.getWidth(), image.getHeight(),
+                 BufferedImage.TYPE_INT_ARGB);
+             for (int x = 0; x < image.getWidth(); x++) {
+                 for (int y = 0; y < image.getHeight(); y++) {
+                     newImage.setRGB(x, y,
+                         image.getRGB(image.getWidth() - 1 - x,
+                             image.getHeight() - 1 - y));
+                 }
+             }
+         } else if (clockwise % 4 == 3) {
+             // 270 degrees clockwise
+             newImage = new BufferedImage(image.getHeight(), image.getWidth(),
+                 BufferedImage.TYPE_INT_ARGB);
+             for (int x = 0; x < image.getWidth(); x++) {
+                 for (int y = 0; y < image.getHeight(); y++) {
+                     newImage.setRGB(y, x,
+                         image.getRGB(image.getWidth() - 1 - x, y));
+                 }
+             }
+         }
+         return newImage;
+     }
+ }
index 0000000000000000000000000000000000000000,15db1da3dd4f61fa3e387c314bc4291e38a8ec47..15db1da3dd4f61fa3e387c314bc4291e38a8ec47
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,291 +1,291 @@@
+ /*
+  * Jexer - Java Text User Interface
+  *
+  * The MIT License (MIT)
+  *
+  * 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"),
+  * to deal in the Software without restriction, including without limitation
+  * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+  * and/or sell copies of the Software, and to permit persons to whom the
+  * Software is furnished to do so, subject to the following conditions:
+  *
+  * The above copyright notice and this permission notice shall be included in
+  * all copies or substantial portions of the Software.
+  *
+  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+  * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+  * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+  * DEALINGS IN THE SOFTWARE.
+  *
+  * @author Kevin Lamonte [kevin.lamonte@gmail.com]
+  * @version 1
+  */
+ package jexer;
+ import java.awt.image.BufferedImage;
+ import java.io.File;
+ import java.io.IOException;
+ import java.util.ResourceBundle;
+ import javax.imageio.ImageIO;
+ import jexer.event.TKeypressEvent;
+ import jexer.event.TMouseEvent;
+ import jexer.event.TResizeEvent;
+ import static jexer.TKeypress.*;
+ /**
+  * TImageWindow shows an image with scrollbars.
+  */
+ public class TImageWindow extends TScrollableWindow {
+     /**
+      * Translated strings.
+      */
+     private static final ResourceBundle i18n = ResourceBundle.getBundle(TImageWindow.class.getName());
+     // ------------------------------------------------------------------------
+     // Constants --------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * The number of lines to scroll on mouse wheel up/down.
+      */
+     private static final int wheelScrollSize = 3;
+     // ------------------------------------------------------------------------
+     // Variables --------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Hang onto the TImage so I can resize it with the window.
+      */
+     private TImage imageField;
+     // ------------------------------------------------------------------------
+     // Constructors -----------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Public constructor opens a file.
+      *
+      * @param parent the main application
+      * @param file the file to open
+      * @throws IOException if a java.io operation throws
+      */
+     public TImageWindow(final TApplication parent,
+         final File file) throws IOException {
+         this(parent, file, 0, 0, parent.getScreen().getWidth(),
+             parent.getDesktopBottom() - parent.getDesktopTop());
+     }
+     /**
+      * Public constructor opens a file.
+      *
+      * @param parent the main application
+      * @param file the file to open
+      * @param x column relative to parent
+      * @param y row relative to parent
+      * @param width width of window
+      * @param height height of window
+      * @throws IOException if a java.io operation throws
+      */
+     public TImageWindow(final TApplication parent, final File file,
+         final int x, final int y, final int width,
+         final int height) throws IOException {
+         super(parent, file.getName(), x, y, width, height, RESIZABLE);
+         BufferedImage image = ImageIO.read(file);
+         imageField = addImage(0, 0, getWidth() - 2, getHeight() - 2,
+             image, 0, 0);
+         setTitle(file.getName());
+         setupAfterImage();
+     }
+     /**
+      * Setup other fields after the image is created.
+      */
+     private void setupAfterImage() {
+         if (imageField.getRows() < getHeight() - 2) {
+             imageField.setHeight(imageField.getRows());
+             setHeight(imageField.getRows() + 2);
+         }
+         if (imageField.getColumns() < getWidth() - 2) {
+             imageField.setWidth(imageField.getColumns());
+             setWidth(imageField.getColumns() + 2);
+         }
+         hScroller = new THScroller(this,
+             Math.min(Math.max(0, getWidth() - 17), 17),
+             getHeight() - 2,
+             getWidth() - Math.min(Math.max(0, getWidth() - 17), 17) - 3);
+         vScroller = new TVScroller(this, getWidth() - 2, 0, getHeight() - 2);
+         setTopValue(0);
+         setBottomValue(imageField.getRows() - imageField.getHeight());
+         setLeftValue(0);
+         setRightValue(imageField.getColumns() - imageField.getWidth());
+         statusBar = newStatusBar(i18n.getString("statusBar"));
+     }
+     // ------------------------------------------------------------------------
+     // Event handlers ---------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Handle mouse press events.
+      *
+      * @param mouse mouse button press event
+      */
+     @Override
+     public void onMouseDown(final TMouseEvent mouse) {
+         // Use TWidget's code to pass the event to the children.
+         super.onMouseDown(mouse);
+         if (mouse.isMouseWheelUp()) {
+             imageField.setTop(imageField.getTop() - wheelScrollSize);
+         } else if (mouse.isMouseWheelDown()) {
+             imageField.setTop(imageField.getTop() + wheelScrollSize);
+         }
+         setVerticalValue(imageField.getTop());
+     }
+     /**
+      * Handle mouse release events.
+      *
+      * @param mouse mouse button release event
+      */
+     @Override
+     public void onMouseUp(final TMouseEvent mouse) {
+         // Use TWidget's code to pass the event to the children.
+         super.onMouseUp(mouse);
+         if (mouse.isMouse1() && mouseOnVerticalScroller(mouse)) {
+             // Clicked/dragged on vertical scrollbar
+             imageField.setTop(getVerticalValue());
+         }
+         if (mouse.isMouse1() && mouseOnHorizontalScroller(mouse)) {
+             // Clicked/dragged on horizontal scrollbar
+             imageField.setLeft(getHorizontalValue());
+         }
+     }
+     /**
+      * Method that subclasses can override to handle mouse movements.
+      *
+      * @param mouse mouse motion event
+      */
+     @Override
+     public void onMouseMotion(final TMouseEvent mouse) {
+         // Use TWidget's code to pass the event to the children.
+         super.onMouseMotion(mouse);
+         if (mouse.isMouse1() && mouseOnVerticalScroller(mouse)) {
+             // Clicked/dragged on vertical scrollbar
+             imageField.setTop(getVerticalValue());
+         }
+         if (mouse.isMouse1() && mouseOnHorizontalScroller(mouse)) {
+             // Clicked/dragged on horizontal scrollbar
+             imageField.setLeft(getHorizontalValue());
+         }
+     }
+     /**
+      * Handle window/screen resize events.
+      *
+      * @param event resize event
+      */
+     @Override
+     public void onResize(final TResizeEvent event) {
+         if (event.getType() == TResizeEvent.Type.WIDGET) {
+             // Resize the image field
+             TResizeEvent imageSize = new TResizeEvent(TResizeEvent.Type.WIDGET,
+                 event.getWidth() - 2, event.getHeight() - 2);
+             imageField.onResize(imageSize);
+             // Have TScrollableWindow handle the scrollbars
+             super.onResize(event);
+             return;
+         }
+         // Pass to children instead
+         for (TWidget widget: getChildren()) {
+             widget.onResize(event);
+         }
+     }
+     /**
+      * Handle keystrokes.
+      *
+      * @param keypress keystroke event
+      */
+     @Override
+     public void onKeypress(final TKeypressEvent keypress) {
+         if (keypress.equals(kbUp)) {
+             verticalDecrement();
+             imageField.setTop(getVerticalValue());
+             return;
+         }
+         if (keypress.equals(kbDown)) {
+             verticalIncrement();
+             imageField.setTop(getVerticalValue());
+             return;
+         }
+         if (keypress.equals(kbPgUp)) {
+             bigVerticalDecrement();
+             imageField.setTop(getVerticalValue());
+             return;
+         }
+         if (keypress.equals(kbPgDn)) {
+             bigVerticalIncrement();
+             imageField.setTop(getVerticalValue());
+             return;
+         }
+         if (keypress.equals(kbRight)) {
+             horizontalIncrement();
+             imageField.setLeft(getHorizontalValue());
+             return;
+         }
+         if (keypress.equals(kbLeft)) {
+             horizontalDecrement();
+             imageField.setLeft(getHorizontalValue());
+             return;
+         }
+         // We did not take it, let the TImage instance see it.
+         super.onKeypress(keypress);
+         setVerticalValue(imageField.getTop());
+         setBottomValue(imageField.getRows() - imageField.getHeight());
+         setHorizontalValue(imageField.getLeft());
+         setRightValue(imageField.getColumns() - imageField.getWidth());
+     }
+     // ------------------------------------------------------------------------
+     // TWindow ----------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Draw the window.
+      */
+     @Override
+     public void draw() {
+         // Draw as normal.
+         super.draw();
+         // We have to get the scrollbar values after we have let the image
+         // try to draw.
+         setBottomValue(imageField.getRows() - imageField.getHeight());
+         setRightValue(imageField.getColumns() - imageField.getWidth());
+     }
+ }
index 0000000000000000000000000000000000000000,a26fce565c60909e2289db6679ab747c54f4629e..a26fce565c60909e2289db6679ab747c54f4629e
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,1 +1,1 @@@
+ statusBar=Alt-\u2190\u2192-Rotate Left/Right  Alt-\u2191\u2193-Bigger/Smaller  \u2190\u2192\u2191\u2193-Pan  Shift-\u2190\u2192-Scale
diff --combined src/jexer/TInputBox.java
index 0000000000000000000000000000000000000000,d60d0b53f5d8b7e8bc8cadb9a41cfceebc7f2873..d60d0b53f5d8b7e8bc8cadb9a41cfceebc7f2873
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,138 +1,138 @@@
+ /*
+  * Jexer - Java Text User Interface
+  *
+  * The MIT License (MIT)
+  *
+  * 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"),
+  * to deal in the Software without restriction, including without limitation
+  * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+  * and/or sell copies of the Software, and to permit persons to whom the
+  * Software is furnished to do so, subject to the following conditions:
+  *
+  * The above copyright notice and this permission notice shall be included in
+  * all copies or substantial portions of the Software.
+  *
+  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+  * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+  * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+  * DEALINGS IN THE SOFTWARE.
+  *
+  * @author Kevin Lamonte [kevin.lamonte@gmail.com]
+  * @version 1
+  */
+ package jexer;
+ /**
+  * TInputBox is a system-modal dialog with an OK button and a text input
+  * field.  Call it like:
+  *
+  * <pre>
+  * {@code
+  *     box = inputBox(title, caption);
+  *     if (box.getText().equals("yes")) {
+  *         ... the user entered "yes", do stuff ...
+  *     }
+  * }
+  * </pre>
+  *
+  */
+ public class TInputBox extends TMessageBox {
+     // ------------------------------------------------------------------------
+     // Variables --------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * The input field.
+      */
+     private TField field;
+     // ------------------------------------------------------------------------
+     // Constructors -----------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Public constructor.  The input box will be centered on screen.
+      *
+      * @param application TApplication that manages this window
+      * @param title window title, will be centered along the top border
+      * @param caption message to display.  Use embedded newlines to get a
+      * multi-line box.
+      */
+     public TInputBox(final TApplication application, final String title,
+         final String caption) {
+         this(application, title, caption, "", Type.OK);
+     }
+     /**
+      * Public constructor.  The input box will be centered on screen.
+      *
+      * @param application TApplication that manages this window
+      * @param title window title, will be centered along the top border
+      * @param caption message to display.  Use embedded newlines to get a
+      * multi-line box.
+      * @param text initial text to seed the field with
+      */
+     public TInputBox(final TApplication application, final String title,
+         final String caption, final String text) {
+         this(application, title, caption, text, Type.OK);
+     }
+     /**
+      * Public constructor.  The input box will be centered on screen.
+      *
+      * @param application TApplication that manages this window
+      * @param title window title, will be centered along the top border
+      * @param caption message to display.  Use embedded newlines to get a
+      * multi-line box.
+      * @param text initial text to seed the field with
+      * @param type one of the Type constants.  Default is Type.OK.
+      */
+     public TInputBox(final TApplication application, final String title,
+         final String caption, final String text, final Type type) {
+         super(application, title, caption, type, false);
+         for (TWidget widget: getChildren()) {
+             if (widget instanceof TButton) {
+                 widget.setY(widget.getY() + 2);
+             }
+         }
+         setHeight(getHeight() + 2);
+         field = addField(1, getHeight() - 6, getWidth() - 4, false, text);
+         // Set the secondaryThread to run me
+         getApplication().enableSecondaryEventReceiver(this);
+         // Yield to the secondary thread.  When I come back from the
+         // constructor response will already be set.
+         getApplication().yield();
+     }
+     // ------------------------------------------------------------------------
+     // TMessageBox ------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     // TInputBox --------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Retrieve the answer text.
+      *
+      * @return the answer text
+      */
+     public String getText() {
+         return field.getText();
+     }
+ }
diff --combined src/jexer/TKeypress.java
index 0000000000000000000000000000000000000000,c965e7dbab48873ae31a35963d4aaef4231cdcaf..c965e7dbab48873ae31a35963d4aaef4231cdcaf
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,1028 +1,1028 @@@
+ /*
+  * Jexer - Java Text User Interface
+  *
+  * The MIT License (MIT)
+  *
+  * 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"),
+  * to deal in the Software without restriction, including without limitation
+  * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+  * and/or sell copies of the Software, and to permit persons to whom the
+  * Software is furnished to do so, subject to the following conditions:
+  *
+  * The above copyright notice and this permission notice shall be included in
+  * all copies or substantial portions of the Software.
+  *
+  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+  * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+  * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+  * DEALINGS IN THE SOFTWARE.
+  *
+  * @author Kevin Lamonte [kevin.lamonte@gmail.com]
+  * @version 1
+  */
+ package jexer;
+ /**
+  * This class represents keystrokes.
+  */
+ public class TKeypress {
+     // ------------------------------------------------------------------------
+     // Constants --------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     // Various special keystrokes
+     /**
+      * "No key".
+      */
+     public static final int NONE        = 255;
+     /**
+      * Function key F1.
+      */
+     public static final int F1          = 1;
+     /**
+      * Function key F2.
+      */
+     public static final int F2          = 2;
+     /**
+      * Function key F3.
+      */
+     public static final int F3          = 3;
+     /**
+      * Function key F4.
+      */
+     public static final int F4          = 4;
+     /**
+      * Function key F5.
+      */
+     public static final int F5          = 5;
+     /**
+      * Function key F6.
+      */
+     public static final int F6          = 6;
+     /**
+      * Function key F7.
+      */
+     public static final int F7          = 7;
+     /**
+      * Function key F8.
+      */
+     public static final int F8          = 8;
+     /**
+      * Function key F9.
+      */
+     public static final int F9          = 9;
+     /**
+      * Function key F10.
+      */
+     public static final int F10         = 10;
+     /**
+      * Function key F11.
+      */
+     public static final int F11         = 11;
+     /**
+      * Function key F12.
+      */
+     public static final int F12         = 12;
+     /**
+      * Home.
+      */
+     public static final int HOME        = 20;
+     /**
+      * End.
+      */
+     public static final int END         = 21;
+     /**
+      * Page up.
+      */
+     public static final int PGUP        = 22;
+     /**
+      * Page down.
+      */
+     public static final int PGDN        = 23;
+     /**
+      * Insert.
+      */
+     public static final int INS         = 24;
+     /**
+      * Delete.
+      */
+     public static final int DEL         = 25;
+     /**
+      * Right arrow.
+      */
+     public static final int RIGHT       = 30;
+     /**
+      * Left arrow.
+      */
+     public static final int LEFT        = 31;
+     /**
+      * Up arrow.
+      */
+     public static final int UP          = 32;
+     /**
+      * Down arrow.
+      */
+     public static final int DOWN        = 33;
+     /**
+      * Tab.
+      */
+     public static final int TAB         = 40;
+     /**
+      * Back-tab (shift-tab).
+      */
+     public static final int BTAB        = 41;
+     /**
+      * Enter.
+      */
+     public static final int ENTER       = 42;
+     /**
+      * Escape.
+      */
+     public static final int ESC         = 43;
+     // Special "no-key" keypress, used to ignore undefined keystrokes
+     public static final TKeypress kbNoKey = new TKeypress(true,
+             TKeypress.NONE, ' ', false, false, false);
+     // Normal keys
+     public static final TKeypress kbF1 = new TKeypress(true,
+             TKeypress.F1, ' ', false, false, false);
+     public static final TKeypress kbF2 = new TKeypress(true,
+             TKeypress.F2, ' ', false, false, false);
+     public static final TKeypress kbF3 = new TKeypress(true,
+             TKeypress.F3, ' ', false, false, false);
+     public static final TKeypress kbF4 = new TKeypress(true,
+             TKeypress.F4, ' ', false, false, false);
+     public static final TKeypress kbF5 = new TKeypress(true,
+             TKeypress.F5, ' ', false, false, false);
+     public static final TKeypress kbF6 = new TKeypress(true,
+             TKeypress.F6, ' ', false, false, false);
+     public static final TKeypress kbF7 = new TKeypress(true,
+             TKeypress.F7, ' ', false, false, false);
+     public static final TKeypress kbF8 = new TKeypress(true,
+             TKeypress.F8, ' ', false, false, false);
+     public static final TKeypress kbF9 = new TKeypress(true,
+             TKeypress.F9, ' ', false, false, false);
+     public static final TKeypress kbF10 = new TKeypress(true,
+             TKeypress.F10, ' ', false, false, false);
+     public static final TKeypress kbF11 = new TKeypress(true,
+             TKeypress.F11, ' ', false, false, false);
+     public static final TKeypress kbF12 = new TKeypress(true,
+             TKeypress.F12, ' ', false, false, false);
+     public static final TKeypress kbAltF1 = new TKeypress(true,
+             TKeypress.F1, ' ', true, false, false);
+     public static final TKeypress kbAltF2 = new TKeypress(true,
+             TKeypress.F2, ' ', true, false, false);
+     public static final TKeypress kbAltF3 = new TKeypress(true,
+             TKeypress.F3, ' ', true, false, false);
+     public static final TKeypress kbAltF4 = new TKeypress(true,
+             TKeypress.F4, ' ', true, false, false);
+     public static final TKeypress kbAltF5 = new TKeypress(true,
+             TKeypress.F5, ' ', true, false, false);
+     public static final TKeypress kbAltF6 = new TKeypress(true,
+             TKeypress.F6, ' ', true, false, false);
+     public static final TKeypress kbAltF7 = new TKeypress(true,
+             TKeypress.F7, ' ', true, false, false);
+     public static final TKeypress kbAltF8 = new TKeypress(true,
+             TKeypress.F8, ' ', true, false, false);
+     public static final TKeypress kbAltF9 = new TKeypress(true,
+             TKeypress.F9, ' ', true, false, false);
+     public static final TKeypress kbAltF10 = new TKeypress(true,
+             TKeypress.F10, ' ', true, false, false);
+     public static final TKeypress kbAltF11 = new TKeypress(true,
+             TKeypress.F11, ' ', true, false, false);
+     public static final TKeypress kbAltF12 = new TKeypress(true,
+             TKeypress.F12, ' ', true, false, false);
+     public static final TKeypress kbCtrlF1 = new TKeypress(true,
+             TKeypress.F1, ' ', false, true, false);
+     public static final TKeypress kbCtrlF2 = new TKeypress(true,
+             TKeypress.F2, ' ', false, true, false);
+     public static final TKeypress kbCtrlF3 = new TKeypress(true,
+             TKeypress.F3, ' ', false, true, false);
+     public static final TKeypress kbCtrlF4 = new TKeypress(true,
+             TKeypress.F4, ' ', false, true, false);
+     public static final TKeypress kbCtrlF5 = new TKeypress(true,
+             TKeypress.F5, ' ', false, true, false);
+     public static final TKeypress kbCtrlF6 = new TKeypress(true,
+             TKeypress.F6, ' ', false, true, false);
+     public static final TKeypress kbCtrlF7 = new TKeypress(true,
+             TKeypress.F7, ' ', false, true, false);
+     public static final TKeypress kbCtrlF8 = new TKeypress(true,
+             TKeypress.F8, ' ', false, true, false);
+     public static final TKeypress kbCtrlF9 = new TKeypress(true,
+             TKeypress.F9, ' ', false, true, false);
+     public static final TKeypress kbCtrlF10 = new TKeypress(true,
+             TKeypress.F10, ' ', false, true, false);
+     public static final TKeypress kbCtrlF11 = new TKeypress(true,
+             TKeypress.F11, ' ', false, true, false);
+     public static final TKeypress kbCtrlF12 = new TKeypress(true,
+             TKeypress.F12, ' ', false, true, false);
+     public static final TKeypress kbShiftF1 = new TKeypress(true,
+             TKeypress.F1, ' ', false, false, true);
+     public static final TKeypress kbShiftF2 = new TKeypress(true,
+             TKeypress.F2, ' ', false, false, true);
+     public static final TKeypress kbShiftF3 = new TKeypress(true,
+             TKeypress.F3, ' ', false, false, true);
+     public static final TKeypress kbShiftF4 = new TKeypress(true,
+             TKeypress.F4, ' ', false, false, true);
+     public static final TKeypress kbShiftF5 = new TKeypress(true,
+             TKeypress.F5, ' ', false, false, true);
+     public static final TKeypress kbShiftF6 = new TKeypress(true,
+             TKeypress.F6, ' ', false, false, true);
+     public static final TKeypress kbShiftF7 = new TKeypress(true,
+             TKeypress.F7, ' ', false, false, true);
+     public static final TKeypress kbShiftF8 = new TKeypress(true,
+             TKeypress.F8, ' ', false, false, true);
+     public static final TKeypress kbShiftF9 = new TKeypress(true,
+             TKeypress.F9, ' ', false, false, true);
+     public static final TKeypress kbShiftF10 = new TKeypress(true,
+             TKeypress.F10, ' ', false, false, true);
+     public static final TKeypress kbShiftF11 = new TKeypress(true,
+             TKeypress.F11, ' ', false, false, true);
+     public static final TKeypress kbShiftF12 = new TKeypress(true,
+             TKeypress.F12, ' ', false, false, true);
+     public static final TKeypress kbEnter = new TKeypress(true,
+             TKeypress.ENTER, ' ', false, false, false);
+     public static final TKeypress kbTab = new TKeypress(true,
+             TKeypress.TAB, ' ', false, false, false);
+     public static final TKeypress kbEsc = new TKeypress(true,
+             TKeypress.ESC, ' ', false, false, false);
+     public static final TKeypress kbHome = new TKeypress(true,
+             TKeypress.HOME, ' ', false, false, false);
+     public static final TKeypress kbEnd = new TKeypress(true,
+             TKeypress.END, ' ', false, false, false);
+     public static final TKeypress kbPgUp = new TKeypress(true,
+             TKeypress.PGUP, ' ', false, false, false);
+     public static final TKeypress kbPgDn = new TKeypress(true,
+             TKeypress.PGDN, ' ', false, false, false);
+     public static final TKeypress kbIns = new TKeypress(true,
+             TKeypress.INS, ' ', false, false, false);
+     public static final TKeypress kbDel = new TKeypress(true,
+             TKeypress.DEL, ' ', false, false, false);
+     public static final TKeypress kbUp = new TKeypress(true,
+             TKeypress.UP, ' ', false, false, false);
+     public static final TKeypress kbDown = new TKeypress(true,
+             TKeypress.DOWN, ' ', false, false, false);
+     public static final TKeypress kbLeft = new TKeypress(true,
+             TKeypress.LEFT, ' ', false, false, false);
+     public static final TKeypress kbRight = new TKeypress(true,
+             TKeypress.RIGHT, ' ', false, false, false);
+     public static final TKeypress kbAltEnter = new TKeypress(true,
+             TKeypress.ENTER, ' ', true, false, false);
+     public static final TKeypress kbAltTab = new TKeypress(true,
+             TKeypress.TAB, ' ', true, false, false);
+     public static final TKeypress kbAltEsc = new TKeypress(true,
+             TKeypress.ESC, ' ', true, false, false);
+     public static final TKeypress kbAltHome = new TKeypress(true,
+             TKeypress.HOME, ' ', true, false, false);
+     public static final TKeypress kbAltEnd = new TKeypress(true,
+             TKeypress.END, ' ', true, false, false);
+     public static final TKeypress kbAltPgUp = new TKeypress(true,
+             TKeypress.PGUP, ' ', true, false, false);
+     public static final TKeypress kbAltPgDn = new TKeypress(true,
+             TKeypress.PGDN, ' ', true, false, false);
+     public static final TKeypress kbAltIns = new TKeypress(true,
+             TKeypress.INS, ' ', true, false, false);
+     public static final TKeypress kbAltDel = new TKeypress(true,
+             TKeypress.DEL, ' ', true, false, false);
+     public static final TKeypress kbAltUp = new TKeypress(true,
+             TKeypress.UP, ' ', true, false, false);
+     public static final TKeypress kbAltDown = new TKeypress(true,
+             TKeypress.DOWN, ' ', true, false, false);
+     public static final TKeypress kbAltLeft = new TKeypress(true,
+             TKeypress.LEFT, ' ', true, false, false);
+     public static final TKeypress kbAltRight = new TKeypress(true,
+             TKeypress.RIGHT, ' ', true, false, false);
+     public static final TKeypress kbCtrlEnter = new TKeypress(true,
+             TKeypress.ENTER, ' ', false, true, false);
+     public static final TKeypress kbCtrlTab = new TKeypress(true,
+             TKeypress.TAB, ' ', false, true, false);
+     public static final TKeypress kbCtrlEsc = new TKeypress(true,
+             TKeypress.ESC, ' ', false, true, false);
+     public static final TKeypress kbCtrlHome = new TKeypress(true,
+             TKeypress.HOME, ' ', false, true, false);
+     public static final TKeypress kbCtrlEnd = new TKeypress(true,
+             TKeypress.END, ' ', false, true, false);
+     public static final TKeypress kbCtrlPgUp = new TKeypress(true,
+             TKeypress.PGUP, ' ', false, true, false);
+     public static final TKeypress kbCtrlPgDn = new TKeypress(true,
+             TKeypress.PGDN, ' ', false, true, false);
+     public static final TKeypress kbCtrlIns = new TKeypress(true,
+             TKeypress.INS, ' ', false, true, false);
+     public static final TKeypress kbCtrlDel = new TKeypress(true,
+             TKeypress.DEL, ' ', false, true, false);
+     public static final TKeypress kbCtrlUp = new TKeypress(true,
+             TKeypress.UP, ' ', false, true, false);
+     public static final TKeypress kbCtrlDown = new TKeypress(true,
+             TKeypress.DOWN, ' ', false, true, false);
+     public static final TKeypress kbCtrlLeft = new TKeypress(true,
+             TKeypress.LEFT, ' ', false, true, false);
+     public static final TKeypress kbCtrlRight = new TKeypress(true,
+             TKeypress.RIGHT, ' ', false, true, false);
+     public static final TKeypress kbShiftEnter = new TKeypress(true,
+             TKeypress.ENTER, ' ', false, false, true);
+     public static final TKeypress kbShiftTab = new TKeypress(true,
+             TKeypress.TAB, ' ', false, false, true);
+     public static final TKeypress kbBackTab = new TKeypress(true,
+             TKeypress.BTAB, ' ', false, false, false);
+     public static final TKeypress kbShiftEsc = new TKeypress(true,
+             TKeypress.ESC, ' ', false, false, true);
+     public static final TKeypress kbShiftHome = new TKeypress(true,
+             TKeypress.HOME, ' ', false, false, true);
+     public static final TKeypress kbShiftEnd = new TKeypress(true,
+             TKeypress.END, ' ', false, false, true);
+     public static final TKeypress kbShiftPgUp = new TKeypress(true,
+             TKeypress.PGUP, ' ', false, false, true);
+     public static final TKeypress kbShiftPgDn = new TKeypress(true,
+             TKeypress.PGDN, ' ', false, false, true);
+     public static final TKeypress kbShiftIns = new TKeypress(true,
+             TKeypress.INS, ' ', false, false, true);
+     public static final TKeypress kbShiftDel = new TKeypress(true,
+             TKeypress.DEL, ' ', false, false, true);
+     public static final TKeypress kbShiftUp = new TKeypress(true,
+             TKeypress.UP, ' ', false, false, true);
+     public static final TKeypress kbShiftDown = new TKeypress(true,
+             TKeypress.DOWN, ' ', false, false, true);
+     public static final TKeypress kbShiftLeft = new TKeypress(true,
+             TKeypress.LEFT, ' ', false, false, true);
+     public static final TKeypress kbShiftRight = new TKeypress(true,
+             TKeypress.RIGHT, ' ', false, false, true);
+     public static final TKeypress kbA = new TKeypress(false,
+             0, 'a', false, false, false);
+     public static final TKeypress kbB = new TKeypress(false,
+             0, 'b', false, false, false);
+     public static final TKeypress kbC = new TKeypress(false,
+             0, 'c', false, false, false);
+     public static final TKeypress kbD = new TKeypress(false,
+             0, 'd', false, false, false);
+     public static final TKeypress kbE = new TKeypress(false,
+             0, 'e', false, false, false);
+     public static final TKeypress kbF = new TKeypress(false,
+             0, 'f', false, false, false);
+     public static final TKeypress kbG = new TKeypress(false,
+             0, 'g', false, false, false);
+     public static final TKeypress kbH = new TKeypress(false,
+             0, 'h', false, false, false);
+     public static final TKeypress kbI = new TKeypress(false,
+             0, 'i', false, false, false);
+     public static final TKeypress kbJ = new TKeypress(false,
+             0, 'j', false, false, false);
+     public static final TKeypress kbK = new TKeypress(false,
+             0, 'k', false, false, false);
+     public static final TKeypress kbL = new TKeypress(false,
+             0, 'l', false, false, false);
+     public static final TKeypress kbM = new TKeypress(false,
+             0, 'm', false, false, false);
+     public static final TKeypress kbN = new TKeypress(false,
+             0, 'n', false, false, false);
+     public static final TKeypress kbO = new TKeypress(false,
+             0, 'o', false, false, false);
+     public static final TKeypress kbP = new TKeypress(false,
+             0, 'p', false, false, false);
+     public static final TKeypress kbQ = new TKeypress(false,
+             0, 'q', false, false, false);
+     public static final TKeypress kbR = new TKeypress(false,
+             0, 'r', false, false, false);
+     public static final TKeypress kbS = new TKeypress(false,
+             0, 's', false, false, false);
+     public static final TKeypress kbT = new TKeypress(false,
+             0, 't', false, false, false);
+     public static final TKeypress kbU = new TKeypress(false,
+             0, 'u', false, false, false);
+     public static final TKeypress kbV = new TKeypress(false,
+             0, 'v', false, false, false);
+     public static final TKeypress kbW = new TKeypress(false,
+             0, 'w', false, false, false);
+     public static final TKeypress kbX = new TKeypress(false,
+             0, 'x', false, false, false);
+     public static final TKeypress kbY = new TKeypress(false,
+             0, 'y', false, false, false);
+     public static final TKeypress kbZ = new TKeypress(false,
+             0, 'z', false, false, false);
+     public static final TKeypress kbSpace = new TKeypress(false,
+             0, ' ', false, false, false);
+     public static final TKeypress kbAltA = new TKeypress(false,
+             0, 'a', true, false, false);
+     public static final TKeypress kbAltB = new TKeypress(false,
+             0, 'b', true, false, false);
+     public static final TKeypress kbAltC = new TKeypress(false,
+             0, 'c', true, false, false);
+     public static final TKeypress kbAltD = new TKeypress(false,
+             0, 'd', true, false, false);
+     public static final TKeypress kbAltE = new TKeypress(false,
+             0, 'e', true, false, false);
+     public static final TKeypress kbAltF = new TKeypress(false,
+             0, 'f', true, false, false);
+     public static final TKeypress kbAltG = new TKeypress(false,
+             0, 'g', true, false, false);
+     public static final TKeypress kbAltH = new TKeypress(false,
+             0, 'h', true, false, false);
+     public static final TKeypress kbAltI = new TKeypress(false,
+             0, 'i', true, false, false);
+     public static final TKeypress kbAltJ = new TKeypress(false,
+             0, 'j', true, false, false);
+     public static final TKeypress kbAltK = new TKeypress(false,
+             0, 'k', true, false, false);
+     public static final TKeypress kbAltL = new TKeypress(false,
+             0, 'l', true, false, false);
+     public static final TKeypress kbAltM = new TKeypress(false,
+             0, 'm', true, false, false);
+     public static final TKeypress kbAltN = new TKeypress(false,
+             0, 'n', true, false, false);
+     public static final TKeypress kbAltO = new TKeypress(false,
+             0, 'o', true, false, false);
+     public static final TKeypress kbAltP = new TKeypress(false,
+             0, 'p', true, false, false);
+     public static final TKeypress kbAltQ = new TKeypress(false,
+             0, 'q', true, false, false);
+     public static final TKeypress kbAltR = new TKeypress(false,
+             0, 'r', true, false, false);
+     public static final TKeypress kbAltS = new TKeypress(false,
+             0, 's', true, false, false);
+     public static final TKeypress kbAltT = new TKeypress(false,
+             0, 't', true, false, false);
+     public static final TKeypress kbAltU = new TKeypress(false,
+             0, 'u', true, false, false);
+     public static final TKeypress kbAltV = new TKeypress(false,
+             0, 'v', true, false, false);
+     public static final TKeypress kbAltW = new TKeypress(false,
+             0, 'w', true, false, false);
+     public static final TKeypress kbAltX = new TKeypress(false,
+             0, 'x', true, false, false);
+     public static final TKeypress kbAltY = new TKeypress(false,
+             0, 'y', true, false, false);
+     public static final TKeypress kbAltZ = new TKeypress(false,
+             0, 'z', true, false, false);
+     public static final TKeypress kbAlt0 = new TKeypress(false,
+             0, '0', true, false, false);
+     public static final TKeypress kbAlt1 = new TKeypress(false,
+             0, '1', true, false, false);
+     public static final TKeypress kbAlt2 = new TKeypress(false,
+             0, '2', true, false, false);
+     public static final TKeypress kbAlt3 = new TKeypress(false,
+             0, '3', true, false, false);
+     public static final TKeypress kbAlt4 = new TKeypress(false,
+             0, '4', true, false, false);
+     public static final TKeypress kbAlt5 = new TKeypress(false,
+             0, '5', true, false, false);
+     public static final TKeypress kbAlt6 = new TKeypress(false,
+             0, '6', true, false, false);
+     public static final TKeypress kbAlt7 = new TKeypress(false,
+             0, '7', true, false, false);
+     public static final TKeypress kbAlt8 = new TKeypress(false,
+             0, '8', true, false, false);
+     public static final TKeypress kbAlt9 = new TKeypress(false,
+             0, '9', true, false, false);
+     public static final TKeypress kbCtrlA = new TKeypress(false,
+             0, 'A', false, true, false);
+     public static final TKeypress kbCtrlB = new TKeypress(false,
+             0, 'B', false, true, false);
+     public static final TKeypress kbCtrlC = new TKeypress(false,
+             0, 'C', false, true, false);
+     public static final TKeypress kbCtrlD = new TKeypress(false,
+             0, 'D', false, true, false);
+     public static final TKeypress kbCtrlE = new TKeypress(false,
+             0, 'E', false, true, false);
+     public static final TKeypress kbCtrlF = new TKeypress(false,
+             0, 'F', false, true, false);
+     public static final TKeypress kbCtrlG = new TKeypress(false,
+             0, 'G', false, true, false);
+     public static final TKeypress kbCtrlH = new TKeypress(false,
+             0, 'H', false, true, false);
+     public static final TKeypress kbCtrlI = new TKeypress(false,
+             0, 'I', false, true, false);
+     public static final TKeypress kbCtrlJ = new TKeypress(false,
+             0, 'J', false, true, false);
+     public static final TKeypress kbCtrlK = new TKeypress(false,
+             0, 'K', false, true, false);
+     public static final TKeypress kbCtrlL = new TKeypress(false,
+             0, 'L', false, true, false);
+     public static final TKeypress kbCtrlM = new TKeypress(false,
+             0, 'M', false, true, false);
+     public static final TKeypress kbCtrlN = new TKeypress(false,
+             0, 'N', false, true, false);
+     public static final TKeypress kbCtrlO = new TKeypress(false,
+             0, 'O', false, true, false);
+     public static final TKeypress kbCtrlP = new TKeypress(false,
+             0, 'P', false, true, false);
+     public static final TKeypress kbCtrlQ = new TKeypress(false,
+             0, 'Q', false, true, false);
+     public static final TKeypress kbCtrlR = new TKeypress(false,
+             0, 'R', false, true, false);
+     public static final TKeypress kbCtrlS = new TKeypress(false,
+             0, 'S', false, true, false);
+     public static final TKeypress kbCtrlT = new TKeypress(false,
+             0, 'T', false, true, false);
+     public static final TKeypress kbCtrlU = new TKeypress(false,
+             0, 'U', false, true, false);
+     public static final TKeypress kbCtrlV = new TKeypress(false,
+             0, 'V', false, true, false);
+     public static final TKeypress kbCtrlW = new TKeypress(false,
+             0, 'W', false, true, false);
+     public static final TKeypress kbCtrlX = new TKeypress(false,
+             0, 'X', false, true, false);
+     public static final TKeypress kbCtrlY = new TKeypress(false,
+             0, 'Y', false, true, false);
+     public static final TKeypress kbCtrlZ = new TKeypress(false,
+             0, 'Z', false, true, false);
+     public static final TKeypress kbAltShiftA = new TKeypress(false,
+             0, 'A', true, false, true);
+     public static final TKeypress kbAltShiftB = new TKeypress(false,
+             0, 'B', true, false, true);
+     public static final TKeypress kbAltShiftC = new TKeypress(false,
+             0, 'C', true, false, true);
+     public static final TKeypress kbAltShiftD = new TKeypress(false,
+             0, 'D', true, false, true);
+     public static final TKeypress kbAltShiftE = new TKeypress(false,
+             0, 'E', true, false, true);
+     public static final TKeypress kbAltShiftF = new TKeypress(false,
+             0, 'F', true, false, true);
+     public static final TKeypress kbAltShiftG = new TKeypress(false,
+             0, 'G', true, false, true);
+     public static final TKeypress kbAltShiftH = new TKeypress(false,
+             0, 'H', true, false, true);
+     public static final TKeypress kbAltShiftI = new TKeypress(false,
+             0, 'I', true, false, true);
+     public static final TKeypress kbAltShiftJ = new TKeypress(false,
+             0, 'J', true, false, true);
+     public static final TKeypress kbAltShiftK = new TKeypress(false,
+             0, 'K', true, false, true);
+     public static final TKeypress kbAltShiftL = new TKeypress(false,
+             0, 'L', true, false, true);
+     public static final TKeypress kbAltShiftM = new TKeypress(false,
+             0, 'M', true, false, true);
+     public static final TKeypress kbAltShiftN = new TKeypress(false,
+             0, 'N', true, false, true);
+     public static final TKeypress kbAltShiftO = new TKeypress(false,
+             0, 'O', true, false, true);
+     public static final TKeypress kbAltShiftP = new TKeypress(false,
+             0, 'P', true, false, true);
+     public static final TKeypress kbAltShiftQ = new TKeypress(false,
+             0, 'Q', true, false, true);
+     public static final TKeypress kbAltShiftR = new TKeypress(false,
+             0, 'R', true, false, true);
+     public static final TKeypress kbAltShiftS = new TKeypress(false,
+             0, 'S', true, false, true);
+     public static final TKeypress kbAltShiftT = new TKeypress(false,
+             0, 'T', true, false, true);
+     public static final TKeypress kbAltShiftU = new TKeypress(false,
+             0, 'U', true, false, true);
+     public static final TKeypress kbAltShiftV = new TKeypress(false,
+             0, 'V', true, false, true);
+     public static final TKeypress kbAltShiftW = new TKeypress(false,
+             0, 'W', true, false, true);
+     public static final TKeypress kbAltShiftX = new TKeypress(false,
+             0, 'X', true, false, true);
+     public static final TKeypress kbAltShiftY = new TKeypress(false,
+             0, 'Y', true, false, true);
+     public static final TKeypress kbAltShiftZ = new TKeypress(false,
+             0, 'Z', true, false, true);
+     /**
+      * Backspace as ^H.
+      */
+     public static final TKeypress kbBackspace = new TKeypress(false,
+             0, 'H', false, true, false);
+     /**
+      * Backspace as ^?.
+      */
+     public static final TKeypress kbBackspaceDel = new TKeypress(false,
+             0, (char) 0x7F, false, false, false);
+     // ------------------------------------------------------------------------
+     // Variables --------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * If true, ch is meaningless, use keyCode instead.
+      */
+     private boolean isFunctionKey;
+     /**
+      * Will be set to F1, F2, HOME, END, etc. if isKey is true.
+      */
+     private int keyCode;
+     /**
+      * Keystroke modifier ALT.
+      */
+     private boolean alt;
+     /**
+      * Keystroke modifier CTRL.
+      */
+     private boolean ctrl;
+     /**
+      * Keystroke modifier SHIFT.
+      */
+     private boolean shift;
+     /**
+      * The character received.
+      */
+     private int ch;
+     // ------------------------------------------------------------------------
+     // Constructors -----------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Public constructor makes an immutable instance.
+      *
+      * @param isKey is true, this is a function key
+      * @param fnKey the function key code (only valid if isKey is true)
+      * @param ch the character (only valid if fnKey is false)
+      * @param alt if true, ALT was pressed with this keystroke
+      * @param ctrl if true, CTRL was pressed with this keystroke
+      * @param shift if true, SHIFT was pressed with this keystroke
+      */
+     public TKeypress(final boolean isKey, final int fnKey, final int ch,
+             final boolean alt, final boolean ctrl, final boolean shift) {
+         this.isFunctionKey = isKey;
+         this.keyCode       = fnKey;
+         this.ch            = ch;
+         this.alt           = alt;
+         this.ctrl          = ctrl;
+         this.shift         = shift;
+     }
+     // ------------------------------------------------------------------------
+     // TKeypress --------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Getter for isFunctionKey.
+      *
+      * @return if true, ch is meaningless, use keyCode instead
+      */
+     public boolean isFnKey() {
+         return isFunctionKey;
+     }
+     /**
+      * Getter for function key code.
+      *
+      * @return function key code int value (only valid is isKey is true)
+      */
+     public int getKeyCode() {
+         return keyCode;
+     }
+     /**
+      * Getter for ALT.
+      *
+      * @return alt value
+      */
+     public boolean isAlt() {
+         return alt;
+     }
+     /**
+      * Getter for CTRL.
+      *
+      * @return ctrl value
+      */
+     public boolean isCtrl() {
+         return ctrl;
+     }
+     /**
+      * Getter for SHIFT.
+      *
+      * @return shift value
+      */
+     public boolean isShift() {
+         return shift;
+     }
+     /**
+      * Getter for character.
+      *
+      * @return the character (only valid if isKey is false)
+      */
+     public int getChar() {
+         return ch;
+     }
+     /**
+      * Create a duplicate instance.
+      *
+      * @return duplicate intance
+      */
+     public TKeypress dup() {
+         TKeypress keypress = new TKeypress(isFunctionKey, keyCode, ch,
+             alt, ctrl, shift);
+         return keypress;
+     }
+     /**
+      * Comparison check.  All fields must match to return true.
+      *
+      * @param rhs another TKeypress instance
+      * @return true if all fields are equal
+      */
+     @Override
+     public boolean equals(final Object rhs) {
+         if (!(rhs instanceof TKeypress)) {
+             return false;
+         }
+         TKeypress that = (TKeypress) rhs;
+         return ((isFunctionKey == that.isFunctionKey)
+                 && (keyCode == that.keyCode)
+                 && (ch == that.ch)
+                 && (alt == that.alt)
+                 && (ctrl == that.ctrl)
+                 && (shift == that.shift));
+     }
+     /**
+      * Comparison check, omitting the ctrl/alt/shift flags.
+      *
+      * @param rhs another TKeypress instance
+      * @return true if all fields (except for ctrl/alt/shift) are equal
+      */
+     public boolean equalsWithoutModifiers(final Object rhs) {
+         if (!(rhs instanceof TKeypress)) {
+             return false;
+         }
+         TKeypress that = (TKeypress) rhs;
+         return ((isFunctionKey == that.isFunctionKey)
+                 && (keyCode == that.keyCode)
+                 && (ch == that.ch));
+     }
+     /**
+      * Hashcode uses all fields in equals().
+      *
+      * @return the hash
+      */
+     @Override
+     public int hashCode() {
+         int A = 13;
+         int B = 23;
+         int hash = A;
+         hash = (B * hash) + (isFunctionKey ? 1 : 0);
+         hash = (B * hash) + keyCode;
+         hash = (B * hash) + ch;
+         hash = (B * hash) + (alt ? 1 : 0);
+         hash = (B * hash) + (ctrl ? 1 : 0);
+         hash = (B * hash) + (shift ? 1 : 0);
+         return hash;
+     }
+     /**
+      * Make human-readable description of this TKeypress.
+      *
+      * @return displayable String
+      */
+     @Override
+     public String toString() {
+         // Special case: Enter is "<arrow> <line> <angle>"
+         if (equals(kbEnter)) {
+             return "\u25C0\u2500\u2518";
+         }
+         if (equals(kbShiftLeft)) {
+             return "Shift+\u2190";
+         }
+         if (equals(kbShiftRight)) {
+             return "Shift+\u2192";
+         }
+         if (isFunctionKey) {
+             switch (keyCode) {
+             case F1:
+                 return String.format("%s%s%sF1",
+                         ctrl ? "Ctrl+" : "",
+                                 alt ? "Alt+" : "",
+                                         shift ? "Shift+" : "");
+             case F2:
+                 return String.format("%s%s%sF2",
+                         ctrl ? "Ctrl+" : "",
+                                 alt ? "Alt+" : "",
+                                         shift ? "Shift+" : "");
+             case F3:
+                 return String.format("%s%s%sF3",
+                         ctrl ? "Ctrl+" : "",
+                                 alt ? "Alt+" : "",
+                                         shift ? "Shift+" : "");
+             case F4:
+                 return String.format("%s%s%sF4",
+                         ctrl ? "Ctrl+" : "",
+                                 alt ? "Alt+" : "",
+                                         shift ? "Shift+" : "");
+             case F5:
+                 return String.format("%s%s%sF5",
+                         ctrl ? "Ctrl+" : "",
+                                 alt ? "Alt+" : "",
+                                         shift ? "Shift+" : "");
+             case F6:
+                 return String.format("%s%s%sF6",
+                         ctrl ? "Ctrl+" : "",
+                                 alt ? "Alt+" : "",
+                                         shift ? "Shift+" : "");
+             case F7:
+                 return String.format("%s%s%sF7",
+                         ctrl ? "Ctrl+" : "",
+                                 alt ? "Alt+" : "",
+                                         shift ? "Shift+" : "");
+             case F8:
+                 return String.format("%s%s%sF8",
+                         ctrl ? "Ctrl+" : "",
+                                 alt ? "Alt+" : "",
+                                         shift ? "Shift+" : "");
+             case F9:
+                 return String.format("%s%s%sF9",
+                         ctrl ? "Ctrl+" : "",
+                                 alt ? "Alt+" : "",
+                                         shift ? "Shift+" : "");
+             case F10:
+                 return String.format("%s%s%sF10",
+                         ctrl ? "Ctrl+" : "",
+                                 alt ? "Alt+" : "",
+                                         shift ? "Shift+" : "");
+             case F11:
+                 return String.format("%s%s%sF11",
+                         ctrl ? "Ctrl+" : "",
+                                 alt ? "Alt+" : "",
+                                         shift ? "Shift+" : "");
+             case F12:
+                 return String.format("%s%s%sF12",
+                         ctrl ? "Ctrl+" : "",
+                                 alt ? "Alt+" : "",
+                                         shift ? "Shift+" : "");
+             case HOME:
+                 return String.format("%s%s%sHOME",
+                         ctrl ? "Ctrl+" : "",
+                                 alt ? "Alt+" : "",
+                                         shift ? "Shift+" : "");
+             case END:
+                 return String.format("%s%s%sEND",
+                         ctrl ? "Ctrl+" : "",
+                                 alt ? "Alt+" : "",
+                                         shift ? "Shift+" : "");
+             case PGUP:
+                 return String.format("%s%s%sPGUP",
+                         ctrl ? "Ctrl+" : "",
+                                 alt ? "Alt+" : "",
+                                         shift ? "Shift+" : "");
+             case PGDN:
+                 return String.format("%s%s%sPGDN",
+                         ctrl ? "Ctrl+" : "",
+                                 alt ? "Alt+" : "",
+                                         shift ? "Shift+" : "");
+             case INS:
+                 return String.format("%s%s%sINS",
+                         ctrl ? "Ctrl+" : "",
+                                 alt ? "Alt+" : "",
+                                         shift ? "Shift+" : "");
+             case DEL:
+                 return String.format("%s%s%sDEL",
+                         ctrl ? "Ctrl+" : "",
+                                 alt ? "Alt+" : "",
+                                         shift ? "Shift+" : "");
+             case RIGHT:
+                 return String.format("%s%s%sRIGHT",
+                         ctrl ? "Ctrl+" : "",
+                                 alt ? "Alt+" : "",
+                                         shift ? "Shift+" : "");
+             case LEFT:
+                 return String.format("%s%s%sLEFT",
+                         ctrl ? "Ctrl+" : "",
+                                 alt ? "Alt+" : "",
+                                         shift ? "Shift+" : "");
+             case UP:
+                 return String.format("%s%s%sUP",
+                         ctrl ? "Ctrl+" : "",
+                                 alt ? "Alt+" : "",
+                                         shift ? "Shift+" : "");
+             case DOWN:
+                 return String.format("%s%s%sDOWN",
+                         ctrl ? "Ctrl+" : "",
+                                 alt ? "Alt+" : "",
+                                         shift ? "Shift+" : "");
+             case TAB:
+                 return String.format("%s%s%sTAB",
+                         ctrl ? "Ctrl+" : "",
+                                 alt ? "Alt+" : "",
+                                         shift ? "Shift+" : "");
+             case BTAB:
+                 return String.format("%s%s%sBTAB",
+                         ctrl ? "Ctrl+" : "",
+                                 alt ? "Alt+" : "",
+                                         shift ? "Shift+" : "");
+             case ENTER:
+                 return String.format("%s%s%sENTER",
+                         ctrl ? "Ctrl+" : "",
+                                 alt ? "Alt+" : "",
+                                         shift ? "Shift+" : "");
+             case ESC:
+                 return String.format("%s%s%sESC",
+                         ctrl ? "Ctrl+" : "",
+                                 alt ? "Alt+" : "",
+                                         shift ? "Shift+" : "");
+             default:
+                 return String.format("--UNKNOWN--");
+             }
+         } else {
+             if (alt && !shift && !ctrl) {
+                 // Alt-X
+                 return String.format("Alt+%c", Character.toUpperCase(ch));
+             } else if (!alt && shift && !ctrl) {
+                 // Shift-X
+                 return String.format("%c", ch);
+             } else if (!alt && !shift && ctrl) {
+                 // Ctrl-X
+                 return String.format("Ctrl+%c", ch);
+             } else if (alt && shift && !ctrl) {
+                 // Alt-Shift-X
+                 return String.format("Alt+Shift+%c", ch);
+             } else if (!alt && shift && ctrl) {
+                 // Ctrl-Shift-X
+                 return String.format("Ctrl+Shift+%c", ch);
+             } else if (alt && !shift && ctrl) {
+                 // Ctrl-Alt-X
+                 return String.format("Ctrl+Alt+%c", Character.toUpperCase(ch));
+             } else if (alt && shift && ctrl) {
+                 // Ctrl-Alt-Shift-X
+                 return String.format("Ctrl+Alt+Shift+%c",
+                         Character.toUpperCase(ch));
+             } else {
+                 // X
+                 return String.format("%c", ch);
+             }
+         }
+     }
+     /**
+      * Convert a keypress to lowercase.  Function keys and alt/ctrl keys are
+      * not converted.
+      *
+      * @return a new instance with the key converted
+      */
+     public TKeypress toLowerCase() {
+         TKeypress newKey = new TKeypress(isFunctionKey, keyCode, ch, alt, ctrl,
+                 shift);
+         if (!isFunctionKey && (ch >= 'A') && (ch <= 'Z') && !ctrl && !alt) {
+             newKey.shift = false;
+             newKey.ch += 32;
+         }
+         return newKey;
+     }
+     /**
+      * Convert a keypress to uppercase.  Function keys and alt/ctrl keys are
+      * not converted.
+      *
+      * @return a new instance with the key converted
+      */
+     public TKeypress toUpperCase() {
+         TKeypress newKey = new TKeypress(isFunctionKey, keyCode, ch, alt, ctrl,
+                 shift);
+         if (!isFunctionKey && (ch >= 'a') && (ch <= 'z') && !ctrl && !alt) {
+             newKey.shift = true;
+             newKey.ch -= 32;
+         }
+         return newKey;
+     }
+ }
diff --combined src/jexer/TLabel.java
index 0000000000000000000000000000000000000000,cc341cfa862964ed9097c425ea4d10b55627ac0c..cc341cfa862964ed9097c425ea4d10b55627ac0c
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,275 +1,275 @@@
+ /*
+  * Jexer - Java Text User Interface
+  *
+  * The MIT License (MIT)
+  *
+  * 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"),
+  * to deal in the Software without restriction, including without limitation
+  * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+  * and/or sell copies of the Software, and to permit persons to whom the
+  * Software is furnished to do so, subject to the following conditions:
+  *
+  * The above copyright notice and this permission notice shall be included in
+  * all copies or substantial portions of the Software.
+  *
+  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+  * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+  * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+  * DEALINGS IN THE SOFTWARE.
+  *
+  * @author Kevin Lamonte [kevin.lamonte@gmail.com]
+  * @version 1
+  */
+ package jexer;
+ import jexer.bits.CellAttributes;
+ import jexer.bits.MnemonicString;
+ import jexer.bits.StringUtils;
+ /**
+  * TLabel implements a simple label, with an optional mnemonic hotkey action
+  * associated with it.
+  */
+ public class TLabel extends TWidget {
+     // ------------------------------------------------------------------------
+     // Variables --------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * The shortcut and label.
+      */
+     private MnemonicString mnemonic;
+     /**
+      * The action to perform when the mnemonic shortcut is pressed.
+      */
+     private TAction action;
+     /**
+      * Label color.
+      */
+     private String colorKey;
+     /**
+      * If true, use the window's background color.
+      */
+     private boolean useWindowBackground = true;
+     // ------------------------------------------------------------------------
+     // Constructors -----------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Public constructor, using the default "tlabel" for colorKey.
+      *
+      * @param parent parent widget
+      * @param text label on the screen
+      * @param x column relative to parent
+      * @param y row relative to parent
+      */
+     public TLabel(final TWidget parent, final String text, final int x,
+         final int y) {
+         this(parent, text, x, y, "tlabel");
+     }
+     /**
+      * Public constructor, using the default "tlabel" for colorKey.
+      *
+      * @param parent parent widget
+      * @param text label on the screen
+      * @param x column relative to parent
+      * @param y row relative to parent
+      * @param action to call when shortcut is pressed
+      */
+     public TLabel(final TWidget parent, final String text, final int x,
+         final int y, final TAction action) {
+         this(parent, text, x, y, "tlabel", action);
+     }
+     /**
+      * Public constructor.
+      *
+      * @param parent parent widget
+      * @param text label on the screen
+      * @param x column relative to parent
+      * @param y row relative to parent
+      * @param colorKey ColorTheme key color to use for foreground text
+      */
+     public TLabel(final TWidget parent, final String text, final int x,
+         final int y, final String colorKey) {
+         this(parent, text, x, y, colorKey, true);
+     }
+     /**
+      * Public constructor.
+      *
+      * @param parent parent widget
+      * @param text label on the screen
+      * @param x column relative to parent
+      * @param y row relative to parent
+      * @param colorKey ColorTheme key color to use for foreground text
+      * @param action to call when shortcut is pressed
+      */
+     public TLabel(final TWidget parent, final String text, final int x,
+         final int y, final String colorKey, final TAction action) {
+         this(parent, text, x, y, colorKey, true, action);
+     }
+     /**
+      * Public constructor.
+      *
+      * @param parent parent widget
+      * @param text label on the screen
+      * @param x column relative to parent
+      * @param y row relative to parent
+      * @param colorKey ColorTheme key color to use for foreground text
+      * @param useWindowBackground if true, use the window's background color
+      */
+     public TLabel(final TWidget parent, final String text, final int x,
+         final int y, final String colorKey, final boolean useWindowBackground) {
+         this(parent, text, x, y, colorKey, useWindowBackground, null);
+     }
+     /**
+      * Public constructor.
+      *
+      * @param parent parent widget
+      * @param text label on the screen
+      * @param x column relative to parent
+      * @param y row relative to parent
+      * @param colorKey ColorTheme key color to use for foreground text
+      * @param useWindowBackground if true, use the window's background color
+      * @param action to call when shortcut is pressed
+      */
+     public TLabel(final TWidget parent, final String text, final int x,
+         final int y, final String colorKey, final boolean useWindowBackground,
+         final TAction action) {
+         // Set parent and window
+         super(parent, false, x, y, 0, 1);
+         setLabel(text);
+         this.colorKey = colorKey;
+         this.useWindowBackground = useWindowBackground;
+         this.action = action;
+     }
+     // ------------------------------------------------------------------------
+     // TWidget ----------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Override TWidget's width: we can only set width at construction time.
+      *
+      * @param width new widget width (ignored)
+      */
+     @Override
+     public void setWidth(final int width) {
+         // Do nothing
+     }
+     /**
+      * Override TWidget's height: we can only set height at construction
+      * time.
+      *
+      * @param height new widget height (ignored)
+      */
+     @Override
+     public void setHeight(final int height) {
+         // Do nothing
+     }
+     /**
+      * Draw a static label.
+      */
+     @Override
+     public void draw() {
+         // Setup my color
+         CellAttributes color = new CellAttributes();
+         CellAttributes mnemonicColor = new CellAttributes();
+         color.setTo(getTheme().getColor(colorKey));
+         mnemonicColor.setTo(getTheme().getColor("tlabel.mnemonic"));
+         if (useWindowBackground) {
+             CellAttributes background = getWindow().getBackground();
+             color.setBackColor(background.getBackColor());
+             mnemonicColor.setBackColor(background.getBackColor());
+         }
+         putStringXY(0, 0, mnemonic.getRawLabel(), color);
+         if (mnemonic.getScreenShortcutIdx() >= 0) {
+             putCharXY(mnemonic.getScreenShortcutIdx(), 0,
+                 mnemonic.getShortcut(), mnemonicColor);
+         }
+     }
+     // ------------------------------------------------------------------------
+     // TLabel -----------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Get label raw text.
+      *
+      * @return label text
+      */
+     public String getLabel() {
+         return mnemonic.getRawLabel();
+     }
+     /**
+      * Get the mnemonic string for this label.
+      *
+      * @return mnemonic string
+      */
+     public MnemonicString getMnemonic() {
+         return mnemonic;
+     }
+     /**
+      * Set label text.
+      *
+      * @param label new label text
+      */
+     public void setLabel(final String label) {
+         mnemonic = new MnemonicString(label);
+         super.setWidth(StringUtils.width(mnemonic.getRawLabel()));
+     }
+     /**
+      * Get the label color.
+      *
+      * @return the ColorTheme key color to use for foreground text
+      */
+     public String getColorKey() {
+         return colorKey;
+     }
+     /**
+      * Set the label color.
+      *
+      * @param colorKey ColorTheme key color to use for foreground text
+      */
+     public void setColorKey(final String colorKey) {
+         this.colorKey = colorKey;
+     }
+     /**
+      * Act as though the mnemonic shortcut was pressed.
+      */
+     public void dispatch() {
+         if (action != null) {
+             action.DO(this);
+         }
+     }
+ }
diff --combined src/jexer/TList.java
index 0000000000000000000000000000000000000000,38a994c8215bbba2a53e3c85d0b15bd46c099146..38a994c8215bbba2a53e3c85d0b15bd46c099146
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,536 +1,536 @@@
+ /*
+  * Jexer - Java Text User Interface
+  *
+  * The MIT License (MIT)
+  *
+  * 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"),
+  * to deal in the Software without restriction, including without limitation
+  * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+  * and/or sell copies of the Software, and to permit persons to whom the
+  * Software is furnished to do so, subject to the following conditions:
+  *
+  * The above copyright notice and this permission notice shall be included in
+  * all copies or substantial portions of the Software.
+  *
+  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+  * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+  * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+  * DEALINGS IN THE SOFTWARE.
+  *
+  * @author Kevin Lamonte [kevin.lamonte@gmail.com]
+  * @version 1
+  */
+ package jexer;
+ import java.util.ArrayList;
+ import java.util.List;
+ import jexer.bits.CellAttributes;
+ import jexer.bits.StringUtils;
+ import jexer.event.TKeypressEvent;
+ import jexer.event.TMouseEvent;
+ import static jexer.TKeypress.*;
+ /**
+  * TList shows a list of strings, and lets the user select one.
+  */
+ public class TList extends TScrollableWidget {
+     // ------------------------------------------------------------------------
+     // Variables --------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * The list of strings to display.
+      */
+     private List<String> strings;
+     /**
+      * Selected string.
+      */
+     private int selectedString = -1;
+     /**
+      * Maximum width of a single line.
+      */
+     private int maxLineWidth;
+     /**
+      * The action to perform when the user selects an item (double-clicks or
+      * enter).
+      */
+     protected TAction enterAction = null;
+     /**
+      * The action to perform when the user selects an item (single-click).
+      */
+     protected TAction singleClickAction = null;
+     /**
+      * The action to perform when the user navigates with keyboard.
+      */
+     protected TAction moveAction = null;
+     // ------------------------------------------------------------------------
+     // Constructors -----------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Public constructor.
+      *
+      * @param parent parent widget
+      * @param strings list of strings to show
+      * @param x column relative to parent
+      * @param y row relative to parent
+      * @param width width of text area
+      * @param height height of text area
+      */
+     public TList(final TWidget parent, final List<String> strings, final int x,
+         final int y, final int width, final int height) {
+         this(parent, strings, x, y, width, height, null, null, null);
+     }
+     /**
+      * Public constructor.
+      *
+      * @param parent parent widget
+      * @param strings list of strings to show.  This is allowed to be null
+      * and set later with setList() or by subclasses.
+      * @param x column relative to parent
+      * @param y row relative to parent
+      * @param width width of text area
+      * @param height height of text area
+      * @param enterAction action to perform when an item is selected
+      */
+     public TList(final TWidget parent, final List<String> strings, final int x,
+         final int y, final int width, final int height,
+         final TAction enterAction) {
+         this(parent, strings, x, y, width, height, enterAction, null, null);
+     }
+     /**
+      * Public constructor.
+      *
+      * @param parent parent widget
+      * @param strings list of strings to show.  This is allowed to be null
+      * and set later with setList() or by subclasses.
+      * @param x column relative to parent
+      * @param y row relative to parent
+      * @param width width of text area
+      * @param height height of text area
+      * @param enterAction action to perform when an item is selected
+      * @param moveAction action to perform when the user navigates to a new
+      * item with arrow/page keys
+      */
+     public TList(final TWidget parent, final List<String> strings, final int x,
+         final int y, final int width, final int height,
+         final TAction enterAction, final TAction moveAction) {
+         this(parent, strings, x, y, width, height, enterAction, moveAction,
+             null);
+     }
+     /**
+      * Public constructor.
+      *
+      * @param parent parent widget
+      * @param strings list of strings to show.  This is allowed to be null
+      * and set later with setList() or by subclasses.
+      * @param x column relative to parent
+      * @param y row relative to parent
+      * @param width width of text area
+      * @param height height of text area
+      * @param enterAction action to perform when an item is selected
+      * @param moveAction action to perform when the user navigates to a new
+      * item with arrow/page keys
+      * @param singleClickAction action to perform when the user clicks on an
+      * item
+      */
+     public TList(final TWidget parent, final List<String> strings, final int x,
+         final int y, final int width, final int height,
+         final TAction enterAction, final TAction moveAction,
+         final TAction singleClickAction) {
+         super(parent, x, y, width, height);
+         this.enterAction = enterAction;
+         this.moveAction = moveAction;
+         this.singleClickAction = singleClickAction;
+         this.strings = new ArrayList<String>();
+         if (strings != null) {
+             this.strings.addAll(strings);
+         }
+         hScroller = new THScroller(this, 0, getHeight() - 1, getWidth() - 1);
+         vScroller = new TVScroller(this, getWidth() - 1, 0, getHeight() - 1);
+         reflowData();
+     }
+     // ------------------------------------------------------------------------
+     // Event handlers ---------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Handle mouse press events.
+      *
+      * @param mouse mouse button press event
+      */
+     @Override
+     public void onMouseDown(final TMouseEvent mouse) {
+         if (mouse.isMouseWheelUp()) {
+             verticalDecrement();
+             return;
+         }
+         if (mouse.isMouseWheelDown()) {
+             verticalIncrement();
+             return;
+         }
+         if ((mouse.getX() < getWidth() - 1)
+             && (mouse.getY() < getHeight() - 1)
+         ) {
+             if (getVerticalValue() + mouse.getY() < strings.size()) {
+                 selectedString = getVerticalValue() + mouse.getY();
+                 dispatchSingleClick();
+             }
+             return;
+         }
+         // Pass to children
+         super.onMouseDown(mouse);
+     }
+     /**
+      * Handle mouse double click.
+      *
+      * @param mouse mouse double click event
+      */
+     @Override
+     public void onMouseDoubleClick(final TMouseEvent mouse) {
+         if ((mouse.getX() < getWidth() - 1)
+             && (mouse.getY() < getHeight() - 1)
+         ) {
+             if (getVerticalValue() + mouse.getY() < strings.size()) {
+                 selectedString = getVerticalValue() + mouse.getY();
+                 dispatchEnter();
+             }
+             return;
+         }
+         // Pass to children
+         super.onMouseDoubleClick(mouse);
+     }
+     /**
+      * Handle keystrokes.
+      *
+      * @param keypress keystroke event
+      */
+     @Override
+     public void onKeypress(final TKeypressEvent keypress) {
+         if (keypress.equals(kbLeft)) {
+             horizontalDecrement();
+         } else if (keypress.equals(kbRight)) {
+             horizontalIncrement();
+         } else if (keypress.equals(kbUp)) {
+             if (strings.size() > 0) {
+                 if (selectedString >= 0) {
+                     if (selectedString > 0) {
+                         if (selectedString - getVerticalValue() == 0) {
+                             verticalDecrement();
+                         }
+                         selectedString--;
+                     }
+                 } else {
+                     selectedString = strings.size() - 1;
+                 }
+             }
+             if (selectedString >= 0) {
+                 dispatchMove();
+             }
+         } else if (keypress.equals(kbDown)) {
+             if (strings.size() > 0) {
+                 if (selectedString >= 0) {
+                     if (selectedString < strings.size() - 1) {
+                         selectedString++;
+                         if (selectedString - getVerticalValue() == getHeight() - 1) {
+                             verticalIncrement();
+                         }
+                     }
+                 } else {
+                     selectedString = 0;
+                 }
+             }
+             if (selectedString >= 0) {
+                 dispatchMove();
+             }
+         } else if (keypress.equals(kbPgUp)) {
+             bigVerticalDecrement();
+             if (selectedString >= 0) {
+                 selectedString -= getHeight() - 1;
+                 if (selectedString < 0) {
+                     selectedString = 0;
+                 }
+             }
+             if (selectedString >= 0) {
+                 dispatchMove();
+             }
+         } else if (keypress.equals(kbPgDn)) {
+             bigVerticalIncrement();
+             if (selectedString >= 0) {
+                 selectedString += getHeight() - 1;
+                 if (selectedString > strings.size() - 1) {
+                     selectedString = strings.size() - 1;
+                 }
+             }
+             if (selectedString >= 0) {
+                 dispatchMove();
+             }
+         } else if (keypress.equals(kbHome)) {
+             toTop();
+             if (strings.size() > 0) {
+                 selectedString = 0;
+             }
+             if (selectedString >= 0) {
+                 dispatchMove();
+             }
+         } else if (keypress.equals(kbEnd)) {
+             toBottom();
+             if (strings.size() > 0) {
+                 selectedString = strings.size() - 1;
+             }
+             if (selectedString >= 0) {
+                 dispatchMove();
+             }
+         } else if (keypress.equals(kbTab)) {
+             getParent().switchWidget(true);
+         } else if (keypress.equals(kbShiftTab) || keypress.equals(kbBackTab)) {
+             getParent().switchWidget(false);
+         } else if (keypress.equals(kbEnter)) {
+             if (selectedString >= 0) {
+                 dispatchEnter();
+             }
+         } else {
+             // Pass other keys (tab etc.) on
+             super.onKeypress(keypress);
+         }
+     }
+     // ------------------------------------------------------------------------
+     // TScrollableWidget ------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Override TWidget's width: we need to set child widget widths.
+      *
+      * @param width new widget width
+      */
+     @Override
+     public void setWidth(final int width) {
+         super.setWidth(width);
+         hScroller.setWidth(getWidth() - 1);
+         vScroller.setX(getWidth() - 1);
+     }
+     /**
+      * Override TWidget's height: we need to set child widget heights.
+      * time.
+      *
+      * @param height new widget height
+      */
+     @Override
+     public void setHeight(final int height) {
+         super.setHeight(height);
+         hScroller.setY(getHeight() - 1);
+         vScroller.setHeight(getHeight() - 1);
+     }
+     /**
+      * Resize for a new width/height.
+      */
+     @Override
+     public void reflowData() {
+         // Reset the lines
+         selectedString = -1;
+         maxLineWidth = 0;
+         for (int i = 0; i < strings.size(); i++) {
+             String line = strings.get(i);
+             int lineLength = StringUtils.width(line);
+             if (lineLength > maxLineWidth) {
+                 maxLineWidth = lineLength;
+             }
+         }
+         setBottomValue(strings.size() - getHeight() + 1);
+         if (getBottomValue() < 0) {
+             setBottomValue(0);
+         }
+         setRightValue(maxLineWidth - getWidth() + 1);
+         if (getRightValue() < 0) {
+             setRightValue(0);
+         }
+     }
+     /**
+      * Draw the list.
+      */
+     @Override
+     public void draw() {
+         CellAttributes color = null;
+         int begin = getVerticalValue();
+         int topY = 0;
+         for (int i = begin; i < strings.size(); i++) {
+             String line = strings.get(i);
+             if (getHorizontalValue() < line.length()) {
+                 line = line.substring(getHorizontalValue());
+             } else {
+                 line = "";
+             }
+             if (i == selectedString) {
+                 if (isAbsoluteActive()) {
+                     color = getTheme().getColor("tlist.selected");
+                 } else {
+                     color = getTheme().getColor("tlist.selected.inactive");
+                 }
+             } else if (isAbsoluteActive()) {
+                 color = getTheme().getColor("tlist");
+             } else {
+                 color = getTheme().getColor("tlist.inactive");
+             }
+             String formatString = "%-" + Integer.toString(getWidth() - 1) + "s";
+             putStringXY(0, topY, String.format(formatString, line), color);
+             topY++;
+             if (topY >= getHeight() - 1) {
+                 break;
+             }
+         }
+         if (isAbsoluteActive()) {
+             color = getTheme().getColor("tlist");
+         } else {
+             color = getTheme().getColor("tlist.inactive");
+         }
+         // Pad the rest with blank lines
+         for (int i = topY; i < getHeight() - 1; i++) {
+             hLineXY(0, i, getWidth() - 1, ' ', color);
+         }
+     }
+     // ------------------------------------------------------------------------
+     // TList ------------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Get the selection index.
+      *
+      * @return -1 if nothing is selected, otherwise the index into the list
+      */
+     public final int getSelectedIndex() {
+         return selectedString;
+     }
+     /**
+      * Set the selected string index.
+      *
+      * @param index -1 to unselect, otherwise the index into the list
+      */
+     public final void setSelectedIndex(final int index) {
+         selectedString = index;
+     }
+     /**
+      * Get a selectable string by index.
+      *
+      * @param idx index into list
+      * @return the string at idx in the list
+      */
+     public final String getListItem(final int idx) {
+         return strings.get(idx);
+     }
+     /**
+      * Get the selected string.
+      *
+      * @return the selected string, or null of nothing is selected yet
+      */
+     public final String getSelected() {
+         if ((selectedString >= 0) && (selectedString <= strings.size() - 1)) {
+             return strings.get(selectedString);
+         }
+         return null;
+     }
+     /**
+      * Get the maximum selection index value.
+      *
+      * @return -1 if the list is empty
+      */
+     public final int getMaxSelectedIndex() {
+         return strings.size() - 1;
+     }
+     /**
+      * Get a copy of the list of strings to display.
+      *
+      * @return the list of strings
+      */
+     public final List<String> getList() {
+         return new ArrayList<String>(strings);
+     }
+     /**
+      * Set the new list of strings to display.
+      *
+      * @param list new list of strings
+      */
+     public final void setList(final List<String> list) {
+         strings.clear();
+         strings.addAll(list);
+         reflowData();
+     }
+     /**
+      * Perform user selection action.
+      */
+     public void dispatchEnter() {
+         assert (selectedString >= 0);
+         assert (selectedString < strings.size());
+         if (enterAction != null) {
+             enterAction.DO(this);
+         }
+     }
+     /**
+      * Perform list movement action.
+      */
+     public void dispatchMove() {
+         assert (selectedString >= 0);
+         assert (selectedString < strings.size());
+         if (moveAction != null) {
+             moveAction.DO(this);
+         }
+     }
+     /**
+      * Perform single-click action.
+      */
+     public void dispatchSingleClick() {
+         assert (selectedString >= 0);
+         assert (selectedString < strings.size());
+         if (singleClickAction != null) {
+             singleClickAction.DO(this);
+         }
+     }
+ }
index 0000000000000000000000000000000000000000,6f1e8a69486b05141b085bda4b841ba474aef347..6f1e8a69486b05141b085bda4b841ba474aef347
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,463 +1,463 @@@
+ /*
+  * Jexer - Java Text User Interface
+  *
+  * The MIT License (MIT)
+  *
+  * 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"),
+  * to deal in the Software without restriction, including without limitation
+  * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+  * and/or sell copies of the Software, and to permit persons to whom the
+  * Software is furnished to do so, subject to the following conditions:
+  *
+  * The above copyright notice and this permission notice shall be included in
+  * all copies or substantial portions of the Software.
+  *
+  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+  * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+  * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+  * DEALINGS IN THE SOFTWARE.
+  *
+  * @author Kevin Lamonte [kevin.lamonte@gmail.com]
+  * @version 1
+  */
+ package jexer;
+ import java.util.ArrayList;
+ import java.util.List;
+ import java.util.ResourceBundle;
+ import jexer.bits.StringUtils;
+ import jexer.event.TKeypressEvent;
+ import static jexer.TKeypress.*;
+ /**
+  * TMessageBox is a system-modal dialog with buttons for OK, Cancel, Yes, or
+  * No.  Call it like:
+  *
+  * <pre>
+  * {@code
+  *     box = messageBox(title, caption,
+  *         TMessageBox.Type.OK | TMessageBox.Type.CANCEL);
+  *
+  *     if (box.getResult() == TMessageBox.OK) {
+  *         ... the user pressed OK, do stuff ...
+  *     }
+  * }
+  * </pre>
+  *
+  */
+ public class TMessageBox extends TWindow {
+     /**
+      * Translated strings.
+      */
+     private static final ResourceBundle i18n = ResourceBundle.getBundle(TMessageBox.class.getName());
+     // ------------------------------------------------------------------------
+     // Constants --------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Message boxes have these supported types.
+      */
+     public enum Type {
+         /**
+          * Show an OK button.
+          */
+         OK,
+         /**
+          * Show both OK and Cancel buttons.
+          */
+         OKCANCEL,
+         /**
+          * Show both Yes and No buttons.
+          */
+         YESNO,
+         /**
+          * Show Yes, No, and Cancel buttons.
+          */
+         YESNOCANCEL
+     };
+     /**
+      * Message boxes have these possible results.
+      */
+     public enum Result {
+         /**
+          * User clicked "OK".
+          */
+         OK,
+         /**
+          * User clicked "Cancel".
+          */
+         CANCEL,
+         /**
+          * User clicked "Yes".
+          */
+         YES,
+         /**
+          * User clicked "No".
+          */
+         NO
+     };
+     // ------------------------------------------------------------------------
+     // Variables --------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * The type of this message box.
+      */
+     private Type type;
+     /**
+      * My buttons.
+      */
+     private List<TButton> buttons;
+     /**
+      * Which button was clicked: OK, CANCEL, YES, or NO.
+      */
+     private Result result = Result.OK;
+     // ------------------------------------------------------------------------
+     // Constructors -----------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Public constructor.  The message box will be centered on screen.
+      *
+      * @param application TApplication that manages this window
+      * @param title window title, will be centered along the top border
+      * @param caption message to display.  Use embedded newlines to get a
+      * multi-line box.
+      */
+     public TMessageBox(final TApplication application, final String title,
+         final String caption) {
+         this(application, title, caption, Type.OK, true);
+     }
+     /**
+      * Public constructor.  The message box will be centered on screen.
+      *
+      * @param application TApplication that manages this window
+      * @param title window title, will be centered along the top border
+      * @param caption message to display.  Use embedded newlines to get a
+      * multi-line box.
+      * @param type one of the Type constants.  Default is Type.OK.
+      */
+     public TMessageBox(final TApplication application, final String title,
+         final String caption, final Type type) {
+         this(application, title, caption, type, true);
+     }
+     /**
+      * Public constructor.  The message box will be centered on screen.
+      *
+      * @param application TApplication that manages this window
+      * @param title window title, will be centered along the top border
+      * @param caption message to display.  Use embedded newlines to get a
+      * multi-line box.
+      * @param type one of the Type constants.  Default is Type.OK.
+      * @param yield if true, yield this Thread.  Subclasses need to set this
+      * to false and yield at their end of their constructor intead.
+      */
+     protected TMessageBox(final TApplication application, final String title,
+         final String caption, final Type type, final boolean yield) {
+         // Start as 100x100 at (1, 1).  These will be changed later.
+         super(application, title, 1, 1, 100, 100, CENTERED | MODAL);
+         // Hang onto type so that we can provide more convenience in
+         // onKeypress().
+         this.type = type;
+         // Determine width and height
+         String [] lines = caption.split("\n");
+         int width = StringUtils.width(title) + 12;
+         setHeight(6 + lines.length);
+         for (String line: lines) {
+             if (StringUtils.width(line) + 4 > width) {
+                 width = StringUtils.width(line) + 4;
+             }
+         }
+         setWidth(width);
+         if (getWidth() > getScreen().getWidth()) {
+             setWidth(getScreen().getWidth());
+         }
+         // Re-center window to get an appropriate (x, y)
+         center();
+         // Now add my elements
+         int lineI = 1;
+         for (String line: lines) {
+             addLabel(line, 1, lineI, "twindow.background.modal");
+             lineI++;
+         }
+         // The button line
+         lineI++;
+         buttons = new ArrayList<TButton>();
+         int buttonX = 0;
+         // Setup button actions
+         switch (type) {
+         case OK:
+             result = Result.OK;
+             if (getWidth() < 15) {
+                 setWidth(15);
+             }
+             buttonX = (getWidth() - 11) / 2;
+             buttons.add(addButton(i18n.getString("okButton"), buttonX, lineI,
+                     new TAction() {
+                         public void DO() {
+                             result = Result.OK;
+                             getApplication().closeWindow(TMessageBox.this);
+                         }
+                     }
+                 )
+             );
+             break;
+         case OKCANCEL:
+             result = Result.CANCEL;
+             if (getWidth() < 26) {
+                 setWidth(26);
+             }
+             buttonX = (getWidth() - 22) / 2;
+             buttons.add(addButton(i18n.getString("okButton"), buttonX, lineI,
+                     new TAction() {
+                         public void DO() {
+                             result = Result.OK;
+                             getApplication().closeWindow(TMessageBox.this);
+                         }
+                     }
+                 )
+             );
+             buttonX += 8 + 4;
+             buttons.add(addButton(i18n.getString("cancelButton"), buttonX, lineI,
+                     new TAction() {
+                         public void DO() {
+                             result = Result.CANCEL;
+                             getApplication().closeWindow(TMessageBox.this);
+                         }
+                     }
+                 )
+             );
+             break;
+         case YESNO:
+             result = Result.NO;
+             if (getWidth() < 20) {
+                 setWidth(20);
+             }
+             buttonX = (getWidth() - 16) / 2;
+             buttons.add(addButton(i18n.getString("yesButton"), buttonX, lineI,
+                     new TAction() {
+                         public void DO() {
+                             result = Result.YES;
+                             getApplication().closeWindow(TMessageBox.this);
+                         }
+                     }
+                 )
+             );
+             buttonX += 5 + 4;
+             buttons.add(addButton(i18n.getString("noButton"), buttonX, lineI,
+                     new TAction() {
+                         public void DO() {
+                             result = Result.NO;
+                             getApplication().closeWindow(TMessageBox.this);
+                         }
+                     }
+                 )
+             );
+             break;
+         case YESNOCANCEL:
+             result = Result.CANCEL;
+             if (getWidth() < 31) {
+                 setWidth(31);
+             }
+             buttonX = (getWidth() - 27) / 2;
+             buttons.add(addButton(i18n.getString("yesButton"), buttonX, lineI,
+                     new TAction() {
+                         public void DO() {
+                             result = Result.YES;
+                             getApplication().closeWindow(TMessageBox.this);
+                         }
+                     }
+                 )
+             );
+             buttonX += 5 + 4;
+             buttons.add(addButton(i18n.getString("noButton"), buttonX, lineI,
+                     new TAction() {
+                         public void DO() {
+                             result = Result.NO;
+                             getApplication().closeWindow(TMessageBox.this);
+                         }
+                     }
+                 )
+             );
+             buttonX += 4 + 4;
+             buttons.add(addButton(i18n.getString("cancelButton"), buttonX,
+                     lineI,
+                     new TAction() {
+                         public void DO() {
+                             result = Result.CANCEL;
+                             getApplication().closeWindow(TMessageBox.this);
+                         }
+                     }
+                 )
+             );
+             break;
+         default:
+             throw new IllegalArgumentException("Invalid message box type: " +
+                 type);
+         }
+         if (yield) {
+             // Set the secondaryThread to run me
+             getApplication().enableSecondaryEventReceiver(this);
+             // Yield to the secondary thread.  When I come back from the
+             // constructor response will already be set.
+             getApplication().yield();
+         }
+     }
+     // ------------------------------------------------------------------------
+     // TWindow ----------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Handle keystrokes.
+      *
+      * @param keypress keystroke event
+      */
+     @Override
+     public void onKeypress(final TKeypressEvent keypress) {
+         if (this instanceof TInputBox) {
+             super.onKeypress(keypress);
+             return;
+         }
+         // Some convenience for message boxes: Alt won't be needed for the
+         // buttons.
+         switch (type) {
+         case OK:
+             if (keypress.equals(kbO)) {
+                 buttons.get(0).dispatch();
+                 return;
+             }
+             break;
+         case OKCANCEL:
+             if (keypress.equals(kbO)) {
+                 buttons.get(0).dispatch();
+                 return;
+             } else if (keypress.equals(kbC)) {
+                 buttons.get(1).dispatch();
+                 return;
+             }
+             break;
+         case YESNO:
+             if (keypress.equals(kbY)) {
+                 buttons.get(0).dispatch();
+                 return;
+             } else if (keypress.equals(kbN)) {
+                 buttons.get(1).dispatch();
+                 return;
+             }
+             break;
+         case YESNOCANCEL:
+             if (keypress.equals(kbY)) {
+                 buttons.get(0).dispatch();
+                 return;
+             } else if (keypress.equals(kbN)) {
+                 buttons.get(1).dispatch();
+                 return;
+             } else if (keypress.equals(kbC)) {
+                 buttons.get(2).dispatch();
+                 return;
+             }
+             break;
+         default:
+             throw new IllegalArgumentException("Invalid message box type: " +
+                 type);
+         }
+         super.onKeypress(keypress);
+     }
+     // ------------------------------------------------------------------------
+     // TMessageBox ------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Get the result.
+      *
+      * @return the result: OK, CANCEL, YES, or NO.
+      */
+     public final Result getResult() {
+         return result;
+     }
+     /**
+      * See if the user clicked YES.
+      *
+      * @return true if the user clicked YES
+      */
+     public final boolean isYes() {
+         return (result == Result.YES);
+     }
+     /**
+      * See if the user clicked NO.
+      *
+      * @return true if the user clicked NO
+      */
+     public final boolean isNo() {
+         return (result == Result.NO);
+     }
+     /**
+      * See if the user clicked OK.
+      *
+      * @return true if the user clicked OK
+      */
+     public final boolean isOk() {
+         return (result == Result.OK);
+     }
+     /**
+      * See if the user clicked CANCEL.
+      *
+      * @return true if the user clicked CANCEL
+      */
+     public final boolean isCancel() {
+         return (result == Result.CANCEL);
+     }
+ }
index 0000000000000000000000000000000000000000,04e344ab0398999283484fcf2a2f3407cfb06193..04e344ab0398999283484fcf2a2f3407cfb06193
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,4 +1,4 @@@
+ okButton=\ \ &OK\ \ 
+ cancelButton=&Cancel
+ yesButton=&Yes
+ noButton=&No
diff --combined src/jexer/TPanel.java
index 0000000000000000000000000000000000000000,c38f8e1f50ee1d76b34821fc56f5959597784d84..c38f8e1f50ee1d76b34821fc56f5959597784d84
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,100 +1,100 @@@
+ /*
+  * Jexer - Java Text User Interface
+  *
+  * The MIT License (MIT)
+  *
+  * 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"),
+  * to deal in the Software without restriction, including without limitation
+  * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+  * and/or sell copies of the Software, and to permit persons to whom the
+  * Software is furnished to do so, subject to the following conditions:
+  *
+  * The above copyright notice and this permission notice shall be included in
+  * all copies or substantial portions of the Software.
+  *
+  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+  * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+  * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+  * DEALINGS IN THE SOFTWARE.
+  *
+  * @author Kevin Lamonte [kevin.lamonte@gmail.com]
+  * @version 1
+  */
+ package jexer;
+ import jexer.event.TResizeEvent;
+ /**
+  * TPanel is an empty container for other widgets.
+  */
+ public class TPanel extends TWidget {
+     // ------------------------------------------------------------------------
+     // Variables --------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     // Constructors -----------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Public constructor.
+      *
+      * @param parent parent widget
+      * @param x column relative to parent
+      * @param y row relative to parent
+      * @param width width of widget
+      * @param height height of widget
+      */
+     public TPanel(final TWidget parent, final int x, final int y,
+         final int width, final int height) {
+         super(parent, x, y, width, height);
+     }
+     // ------------------------------------------------------------------------
+     // Event handlers ---------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Method that subclasses can override to handle window/screen resize
+      * events.
+      *
+      * @param resize resize event
+      */
+     @Override
+     public void onResize(final TResizeEvent resize) {
+         if (resize.getType() == TResizeEvent.Type.WIDGET) {
+             if (getChildren().size() == 1) {
+                 TWidget child = getChildren().get(0);
+                 if ((child instanceof TSplitPane)
+                     || (child instanceof TPanel)
+                 ) {
+                     child.onResize(new TResizeEvent(TResizeEvent.Type.WIDGET,
+                             resize.getWidth(), resize.getHeight()));
+                 }
+                 return;
+             }
+         }
+         // Pass on to TWidget.
+         super.onResize(resize);
+     }
+     // ------------------------------------------------------------------------
+     // TWidget ----------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     // TPanel -----------------------------------------------------------------
+     // ------------------------------------------------------------------------
+ }
index 0000000000000000000000000000000000000000,9c200d7dc7dcfea44a2fef0d3f59f8df9505ec81..9c200d7dc7dcfea44a2fef0d3f59f8df9505ec81
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,132 +1,132 @@@
+ /*
+  * Jexer - Java Text User Interface
+  *
+  * The MIT License (MIT)
+  *
+  * 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"),
+  * to deal in the Software without restriction, including without limitation
+  * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+  * and/or sell copies of the Software, and to permit persons to whom the
+  * Software is furnished to do so, subject to the following conditions:
+  *
+  * The above copyright notice and this permission notice shall be included in
+  * all copies or substantial portions of the Software.
+  *
+  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+  * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+  * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+  * DEALINGS IN THE SOFTWARE.
+  *
+  * @author Kevin Lamonte [kevin.lamonte@gmail.com]
+  * @version 1
+  */
+ package jexer;
+ import jexer.bits.CellAttributes;
+ import jexer.bits.GraphicsChars;
+ import jexer.bits.StringUtils;
+ /**
+  * TPasswordField implements an editable text field that displays
+  * stars/asterisks when it is not active.
+  */
+ public class TPasswordField extends TField {
+     // ------------------------------------------------------------------------
+     // Constructors -----------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Public constructor.
+      *
+      * @param parent parent widget
+      * @param x column relative to parent
+      * @param y row relative to parent
+      * @param width visible text width
+      * @param fixed if true, the text cannot exceed the display width
+      */
+     public TPasswordField(final TWidget parent, final int x, final int y,
+         final int width, final boolean fixed) {
+         this(parent, x, y, width, fixed, "", null, null);
+     }
+     /**
+      * Public constructor.
+      *
+      * @param parent parent widget
+      * @param x column relative to parent
+      * @param y row relative to parent
+      * @param width visible text width
+      * @param fixed if true, the text cannot exceed the display width
+      * @param text initial text, default is empty string
+      */
+     public TPasswordField(final TWidget parent, final int x, final int y,
+         final int width, final boolean fixed, final String text) {
+         this(parent, x, y, width, fixed, text, null, null);
+     }
+     /**
+      * Public constructor.
+      *
+      * @param parent parent widget
+      * @param x column relative to parent
+      * @param y row relative to parent
+      * @param width visible text width
+      * @param fixed if true, the text cannot exceed the display width
+      * @param text initial text, default is empty string
+      * @param enterAction function to call when enter key is pressed
+      * @param updateAction function to call when the text is updated
+      */
+     public TPasswordField(final TWidget parent, final int x, final int y,
+         final int width, final boolean fixed, final String text,
+         final TAction enterAction, final TAction updateAction) {
+         // Set parent and window
+         super(parent, x, y, width, fixed, text, enterAction, updateAction);
+     }
+     // ------------------------------------------------------------------------
+     // TField -----------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Draw the text field.
+      */
+     @Override
+     public void draw() {
+         CellAttributes fieldColor;
+         boolean showStars = false;
+         if (isAbsoluteActive()) {
+             fieldColor = getTheme().getColor("tfield.active");
+         } else {
+             fieldColor = getTheme().getColor("tfield.inactive");
+             showStars = true;
+         }
+         int end = windowStart + getWidth();
+         if (end > StringUtils.width(text)) {
+             end = StringUtils.width(text);
+         }
+         hLineXY(0, 0, getWidth(), backgroundChar, fieldColor);
+         if (showStars) {
+             hLineXY(0, 0, getWidth() - 2, '*', fieldColor);
+         } else {
+             putStringXY(0, 0, text.substring(screenToTextPosition(windowStart),
+                     screenToTextPosition(end)), fieldColor);
+         }
+         // Fix the cursor, it will be rendered by TApplication.drawAll().
+         updateCursor();
+     }
+ }
index 0000000000000000000000000000000000000000,38f03373b7c8d5d08f323f0e56fa34bac1516048..38f03373b7c8d5d08f323f0e56fa34bac1516048
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,294 +1,294 @@@
+ /*
+  * Jexer - Java Text User Interface
+  *
+  * The MIT License (MIT)
+  *
+  * 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"),
+  * to deal in the Software without restriction, including without limitation
+  * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+  * and/or sell copies of the Software, and to permit persons to whom the
+  * Software is furnished to do so, subject to the following conditions:
+  *
+  * The above copyright notice and this permission notice shall be included in
+  * all copies or substantial portions of the Software.
+  *
+  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+  * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+  * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+  * DEALINGS IN THE SOFTWARE.
+  *
+  * @author Kevin Lamonte [kevin.lamonte@gmail.com]
+  * @version 1
+  */
+ package jexer;
+ import jexer.bits.CellAttributes;
+ import jexer.bits.GraphicsChars;
+ import jexer.bits.StringUtils;
+ /**
+  * TProgressBar implements a simple progress bar.
+  */
+ public class TProgressBar extends TWidget {
+     // ------------------------------------------------------------------------
+     // Variables --------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Value that corresponds to 0% progress.
+      */
+     private int minValue = 0;
+     /**
+      * Value that corresponds to 100% progress.
+      */
+     private int maxValue = 100;
+     /**
+      * Current value of the progress.
+      */
+     private int value = 0;
+     /**
+      * The left border character.
+      */
+     private int leftBorderChar = GraphicsChars.CP437[0xC3];
+     /**
+      * The filled-in part of the bar.
+      */
+     private int completedChar = GraphicsChars.BOX;
+     /**
+      * The remaining to be filled in part of the bar.
+      */
+     private int remainingChar = GraphicsChars.SINGLE_BAR;
+     /**
+      * The right border character.
+      */
+     private int rightBorderChar = GraphicsChars.CP437[0xB4];
+     // ------------------------------------------------------------------------
+     // Constructors -----------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Public constructor.
+      *
+      * @param parent parent widget
+      * @param x column relative to parent
+      * @param y row relative to parent
+      * @param width width of progress bar
+      * @param value initial value of percent complete
+      */
+     public TProgressBar(final TWidget parent, final int x, final int y,
+         final int width, final int value) {
+         // Set parent and window
+         super(parent, false, x, y, width, 1);
+         this.value = value;
+     }
+     // ------------------------------------------------------------------------
+     // Event handlers ---------------------------------------------------------
+     // ------------------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     // TWidget ----------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Override TWidget's height: we can only set height at construction
+      * time.
+      *
+      * @param height new widget height (ignored)
+      */
+     @Override
+     public void setHeight(final int height) {
+         // Do nothing
+     }
+     /**
+      * Draw a static progress bar.
+      */
+     @Override
+     public void draw() {
+         if (getWidth() <= 2) {
+             // Bail out, we are too narrow to draw anything.
+             return;
+         }
+         CellAttributes completeColor = getTheme().getColor("tprogressbar.complete");
+         CellAttributes incompleteColor = getTheme().getColor("tprogressbar.incomplete");
+         float progress = ((float)value - minValue) / ((float)maxValue - minValue);
+         int progressInt = (int)(progress * 100);
+         int progressUnit = 100 / (getWidth() - 2);
+         putCharXY(0, 0, leftBorderChar, incompleteColor);
+         for (int i = StringUtils.width(leftBorderChar); i < getWidth() - 2;) {
+             float iProgress = (float)i / (getWidth() - 2);
+             int iProgressInt = (int)(iProgress * 100);
+             if (iProgressInt <= progressInt - progressUnit) {
+                 putCharXY(i, 0, completedChar, completeColor);
+                 i += StringUtils.width(completedChar);
+             } else {
+                 putCharXY(i, 0, remainingChar, incompleteColor);
+                 i += StringUtils.width(remainingChar);
+             }
+         }
+         if (value >= maxValue) {
+             putCharXY(getWidth() - StringUtils.width(leftBorderChar) -
+                 StringUtils.width(rightBorderChar), 0, completedChar,
+                 completeColor);
+         } else {
+             putCharXY(getWidth() - StringUtils.width(leftBorderChar) -
+                 StringUtils.width(rightBorderChar), 0, remainingChar,
+                 incompleteColor);
+         }
+         putCharXY(getWidth() - StringUtils.width(rightBorderChar), 0,
+             rightBorderChar, incompleteColor);
+     }
+     // ------------------------------------------------------------------------
+     // TProgressBar -----------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Get the value that corresponds to 0% progress.
+      *
+      * @return the value that corresponds to 0% progress
+      */
+     public int getMinValue() {
+         return minValue;
+     }
+     /**
+      * Set the value that corresponds to 0% progress.
+      *
+      * @param minValue the value that corresponds to 0% progress
+      */
+     public void setMinValue(final int minValue) {
+         this.minValue = minValue;
+     }
+     /**
+      * Get the value that corresponds to 100% progress.
+      *
+      * @return the value that corresponds to 100% progress
+      */
+     public int getMaxValue() {
+         return maxValue;
+     }
+     /**
+      * Set the value that corresponds to 100% progress.
+      *
+      * @param maxValue the value that corresponds to 100% progress
+      */
+     public void setMaxValue(final int maxValue) {
+         this.maxValue = maxValue;
+     }
+     /**
+      * Get the current value of the progress.
+      *
+      * @return the current value of the progress
+      */
+     public int getValue() {
+         return value;
+     }
+     /**
+      * Set the current value of the progress.
+      *
+      * @param value the current value of the progress
+      */
+     public void setValue(final int value) {
+         this.value = value;
+     }
+     /**
+      * Set the left border character.
+      *
+      * @param ch the char to use
+      */
+     public void setLeftBorderChar(final int ch) {
+         leftBorderChar = ch;
+     }
+     /**
+      * Get the left border character.
+      *
+      * @return the char
+      */
+     public int getLeftBorderChar() {
+         return leftBorderChar;
+     }
+     /**
+      * Set the filled-in part of the bar.
+      *
+      * @param ch the char to use
+      */
+     public void setCompletedChar(final int ch) {
+         completedChar = ch;
+     }
+     /**
+      * Get the filled-in part of the bar.
+      *
+      * @return the char
+      */
+     public int getCompletedChar() {
+         return completedChar;
+     }
+     /**
+      * Set the remaining to be filled in part of the bar.
+      *
+      * @param ch the char to use
+      */
+     public void setRemainingChar(final int ch) {
+         remainingChar = ch;
+     }
+     /**
+      * Get the remaining to be filled in part of the bar.
+      *
+      * @return the char
+      */
+     public int getRemainingChar() {
+         return remainingChar;
+     }
+     /**
+      * Set the right border character.
+      *
+      * @param ch the char to use
+      */
+     public void setRightBorderChar(final int ch) {
+         rightBorderChar = ch;
+     }
+     /**
+      * Get the right border character.
+      *
+      * @return the char
+      */
+     public int getRightBorderChar() {
+         return rightBorderChar;
+     }
+ }
index 0000000000000000000000000000000000000000,60a628845ca2c5c920863bd31be7597c95709266..60a628845ca2c5c920863bd31be7597c95709266
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,254 +1,254 @@@
+ /*
+  * Jexer - Java Text User Interface
+  *
+  * The MIT License (MIT)
+  *
+  * 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"),
+  * to deal in the Software without restriction, including without limitation
+  * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+  * and/or sell copies of the Software, and to permit persons to whom the
+  * Software is furnished to do so, subject to the following conditions:
+  *
+  * The above copyright notice and this permission notice shall be included in
+  * all copies or substantial portions of the Software.
+  *
+  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+  * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+  * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+  * DEALINGS IN THE SOFTWARE.
+  *
+  * @author Kevin Lamonte [kevin.lamonte@gmail.com]
+  * @version 1
+  */
+ package jexer;
+ import jexer.bits.CellAttributes;
+ import jexer.bits.GraphicsChars;
+ import jexer.bits.MnemonicString;
+ import jexer.bits.StringUtils;
+ import jexer.event.TKeypressEvent;
+ import jexer.event.TMouseEvent;
+ import static jexer.TKeypress.*;
+ /**
+  * TRadioButton implements a selectable radio button.
+  *
+  * If the user clicks or presses space on this button, it is selected.
+  *
+  * If the user presses escape on this button, it is unselected.
+  */
+ public class TRadioButton extends TWidget {
+     // ------------------------------------------------------------------------
+     // Variables --------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * RadioButton state, true means selected.
+      */
+     private boolean selected = false;
+     /**
+      * The shortcut and radio button label.
+      */
+     private MnemonicString mnemonic;
+     /**
+      * ID for this radio button.  Buttons start counting at 1 in the
+      * RadioGroup.
+      */
+     private int id;
+     // ------------------------------------------------------------------------
+     // Constructors -----------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Public constructor.
+      *
+      * @param parent parent widget
+      * @param x column relative to parent
+      * @param y row relative to parent
+      * @param label label to display next to (right of) the radiobutton
+      * @param id ID for this radio button
+      */
+     public TRadioButton(final TRadioGroup parent, final int x, final int y,
+         final String label, final int id) {
+         // Set parent and window
+         super(parent, x, y, StringUtils.width(label) + 4, 1);
+         mnemonic = new MnemonicString(label);
+         this.id = id;
+         setCursorVisible(true);
+         setCursorX(1);
+     }
+     // ------------------------------------------------------------------------
+     // Event handlers ---------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Returns true if the mouse is currently on the radio button.
+      *
+      * @param mouse mouse event
+      * @return if true the mouse is currently on the radio button
+      */
+     private boolean mouseOnRadioButton(final TMouseEvent mouse) {
+         if ((mouse.getY() == 0)
+             && (mouse.getX() >= 0)
+             && (mouse.getX() <= 2)
+         ) {
+             return true;
+         }
+         return false;
+     }
+     /**
+      * Handle mouse button presses.
+      *
+      * @param mouse mouse button press event
+      */
+     @Override
+     public void onMouseDown(final TMouseEvent mouse) {
+         if ((mouseOnRadioButton(mouse)) && (mouse.isMouse1())) {
+             // Switch state
+             selected = true;
+             ((TRadioGroup) getParent()).setSelected(this);
+         }
+     }
+     /**
+      * Handle keystrokes.
+      *
+      * @param keypress keystroke event
+      */
+     @Override
+     public void onKeypress(final TKeypressEvent keypress) {
+         if (keypress.equals(kbSpace)) {
+             selected = true;
+             ((TRadioGroup) getParent()).setSelected(this);
+             return;
+         }
+         if (keypress.equals(kbEsc)) {
+             TRadioGroup parent = (TRadioGroup) getParent();
+             if (parent.requiresSelection == false) {
+                 selected = false;
+                 parent.setSelected(0);
+             }
+             return;
+         }
+         // Pass to parent for the things we don't care about.
+         super.onKeypress(keypress);
+     }
+     // ------------------------------------------------------------------------
+     // TWidget ----------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Override TWidget's width: we can only set width at construction time.
+      *
+      * @param width new widget width (ignored)
+      */
+     @Override
+     public void setWidth(final int width) {
+         // Do nothing
+     }
+     /**
+      * Override TWidget's height: we can only set height at construction
+      * time.
+      *
+      * @param height new widget height (ignored)
+      */
+     @Override
+     public void setHeight(final int height) {
+         // Do nothing
+     }
+     /**
+      * Draw a radio button with label.
+      */
+     @Override
+     public void draw() {
+         CellAttributes radioButtonColor;
+         CellAttributes mnemonicColor;
+         if (isAbsoluteActive()) {
+             radioButtonColor = getTheme().getColor("tradiobutton.active");
+             mnemonicColor = getTheme().getColor("tradiobutton.mnemonic.highlighted");
+         } else {
+             radioButtonColor = getTheme().getColor("tradiobutton.inactive");
+             mnemonicColor = getTheme().getColor("tradiobutton.mnemonic");
+         }
+         putCharXY(0, 0, '(', radioButtonColor);
+         if (selected) {
+             putCharXY(1, 0, GraphicsChars.CP437[0x07], radioButtonColor);
+         } else {
+             putCharXY(1, 0, ' ', radioButtonColor);
+         }
+         putCharXY(2, 0, ')', radioButtonColor);
+         putStringXY(4, 0, mnemonic.getRawLabel(), radioButtonColor);
+         if (mnemonic.getScreenShortcutIdx() >= 0) {
+             putCharXY(4 + mnemonic.getScreenShortcutIdx(), 0,
+                 mnemonic.getShortcut(), mnemonicColor);
+         }
+     }
+     // ------------------------------------------------------------------------
+     // TRadioButton -----------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Get RadioButton state, true means selected.
+      *
+      * @return if true then this is the one button in the group that is
+      * selected
+      */
+     public boolean isSelected() {
+         return selected;
+     }
+     /**
+      * Set RadioButton state, true means selected.  Note package private
+      * access.
+      *
+      * @param selected if true then this is the one button in the group that
+      * is selected
+      */
+     void setSelected(final boolean selected) {
+         this.selected = selected;
+     }
+     /**
+      * Get ID for this radio button.  Buttons start counting at 1 in the
+      * RadioGroup.
+      *
+      * @return the ID
+      */
+     public int getId() {
+         return id;
+     }
+     /**
+      * Get the mnemonic string for this button.
+      *
+      * @return mnemonic string
+      */
+     public MnemonicString getMnemonic() {
+         return mnemonic;
+     }
+ }
index 0000000000000000000000000000000000000000,a82b074f8ce9a1c4fe6de462433b8d4124507b91..a82b074f8ce9a1c4fe6de462433b8d4124507b91
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,203 +1,203 @@@
+ /*
+  * Jexer - Java Text User Interface
+  *
+  * The MIT License (MIT)
+  *
+  * 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"),
+  * to deal in the Software without restriction, including without limitation
+  * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+  * and/or sell copies of the Software, and to permit persons to whom the
+  * Software is furnished to do so, subject to the following conditions:
+  *
+  * The above copyright notice and this permission notice shall be included in
+  * all copies or substantial portions of the Software.
+  *
+  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+  * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+  * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+  * DEALINGS IN THE SOFTWARE.
+  *
+  * @author Kevin Lamonte [kevin.lamonte@gmail.com]
+  * @version 1
+  */
+ package jexer;
+ import jexer.bits.CellAttributes;
+ import jexer.bits.StringUtils;
+ /**
+  * TRadioGroup is a collection of TRadioButtons with a box and label.
+  */
+ public class TRadioGroup extends TWidget {
+     // ------------------------------------------------------------------------
+     // Variables --------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Label for this radio button group.
+      */
+     private String label;
+     /**
+      * Only one of my children can be selected.
+      */
+     private TRadioButton selectedButton = null;
+     /**
+      * If true, one of the children MUST be selected.  Note package private
+      * access.
+      */
+     boolean requiresSelection = true;
+     // ------------------------------------------------------------------------
+     // Constructors -----------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Public constructor.
+      *
+      * @param parent parent widget
+      * @param x column relative to parent
+      * @param y row relative to parent
+      * @param label label to display on the group box
+      */
+     public TRadioGroup(final TWidget parent, final int x, final int y,
+         final String label) {
+         // Set parent and window
+         super(parent, x, y, StringUtils.width(label) + 4, 2);
+         this.label = label;
+     }
+     // ------------------------------------------------------------------------
+     // TWidget ----------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Override TWidget's width: we can only set width at construction time.
+      *
+      * @param width new widget width (ignored)
+      */
+     @Override
+     public void setWidth(final int width) {
+         // Do nothing
+     }
+     /**
+      * Override TWidget's height: we can only set height at construction
+      * time.
+      *
+      * @param height new widget height (ignored)
+      */
+     @Override
+     public void setHeight(final int height) {
+         // Do nothing
+     }
+     /**
+      * Draw a radio button with label.
+      */
+     @Override
+     public void draw() {
+         CellAttributes radioGroupColor;
+         if (isAbsoluteActive()) {
+             radioGroupColor = getTheme().getColor("tradiogroup.active");
+         } else {
+             radioGroupColor = getTheme().getColor("tradiogroup.inactive");
+         }
+         drawBox(0, 0, getWidth(), getHeight(), radioGroupColor, radioGroupColor,
+             3, false);
+         hLineXY(1, 0, StringUtils.width(label) + 2, ' ', radioGroupColor);
+         putStringXY(2, 0, label, radioGroupColor);
+     }
+     // ------------------------------------------------------------------------
+     // TRadioGroup ------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Get the radio button ID that was selected.
+      *
+      * @return ID of the selected button, or 0 if no button is selected
+      */
+     public int getSelected() {
+         if (selectedButton == null) {
+             return 0;
+         }
+         return selectedButton.getId();
+     }
+     /**
+      * Set the new selected radio button.  Note package private access.
+      *
+      * @param button new button that became selected
+      */
+     void setSelected(final TRadioButton button) {
+         assert (button.isSelected());
+         if ((selectedButton != null) && (selectedButton != button)) {
+             selectedButton.setSelected(false);
+         }
+         selectedButton = button;
+     }
+     /**
+      * Set the new selected radio button.  1-based.
+      *
+      * @param id ID of the selected button, or 0 to unselect
+      */
+     public void setSelected(final int id) {
+         if ((id < 0) || (id > getChildren().size())) {
+             return;
+         }
+         if (id == 0) {
+             for (TWidget widget: getChildren()) {
+                 ((TRadioButton) widget).setSelected(false);
+             }
+             selectedButton = null;
+             return;
+         }
+         assert ((id > 0) && (id <= getChildren().size()));
+         TRadioButton button = (TRadioButton) (getChildren().get(id - 1));
+         button.setSelected(true);
+         selectedButton = button;
+     }
+     /**
+      * Convenience function to add a radio button to this group.
+      *
+      * @param label label to display next to (right of) the radiobutton
+      * @return the new radio button
+      */
+     public TRadioButton addRadioButton(final String label) {
+         int buttonX = 1;
+         int buttonY = getChildren().size() + 1;
+         if (StringUtils.width(label) + 4 > getWidth()) {
+             super.setWidth(StringUtils.width(label) + 7);
+         }
+         super.setHeight(getChildren().size() + 3);
+         TRadioButton button = new TRadioButton(this, buttonX, buttonY, label,
+             getChildren().size() + 1);
+         if (getParent().getLayoutManager() != null) {
+             getParent().getLayoutManager().resetSize(this);
+         }
+         // Default to the first item on the list.
+         activate(getChildren().get(0));
+         return button;
+     }
+ }
index 0000000000000000000000000000000000000000,7d15b2897ab860ba425ad73cc12b9d70db20c0c1..7d15b2897ab860ba425ad73cc12b9d70db20c0c1
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,609 +1,609 @@@
+ /*
+  * Jexer - Java Text User Interface
+  *
+  * The MIT License (MIT)
+  *
+  * 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"),
+  * to deal in the Software without restriction, including without limitation
+  * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+  * and/or sell copies of the Software, and to permit persons to whom the
+  * Software is furnished to do so, subject to the following conditions:
+  *
+  * The above copyright notice and this permission notice shall be included in
+  * all copies or substantial portions of the Software.
+  *
+  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+  * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+  * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+  * DEALINGS IN THE SOFTWARE.
+  *
+  * @author Kevin Lamonte [kevin.lamonte@gmail.com]
+  * @version 1
+  */
+ package jexer;
+ import jexer.event.TResizeEvent;
+ /**
+  * TScrollableWidget is a convenience superclass for widgets that have
+  * scrollbars.
+  */
+ public class TScrollableWidget extends TWidget implements Scrollable {
+     // ------------------------------------------------------------------------
+     // Variables --------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * The horizontal scrollbar.
+      */
+     protected THScroller hScroller = null;
+     /**
+      * The vertical scrollbar.
+      */
+     protected TVScroller vScroller = null;
+     // ------------------------------------------------------------------------
+     // Constructors -----------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Protected constructor.
+      *
+      * @param parent parent widget
+      */
+     protected TScrollableWidget(final TWidget parent) {
+         super(parent);
+     }
+     /**
+      * Protected constructor.
+      *
+      * @param parent parent widget
+      * @param x column relative to parent
+      * @param y row relative to parent
+      * @param width width of widget
+      * @param height height of widget
+      */
+     protected TScrollableWidget(final TWidget parent, final int x, final int y,
+         final int width, final int height) {
+         super(parent, x, y, width, height);
+     }
+     /**
+      * Protected constructor used by subclasses that are disabled by default.
+      *
+      * @param parent parent widget
+      * @param enabled if true assume enabled
+      */
+     protected TScrollableWidget(final TWidget parent, final boolean enabled) {
+         super(parent, enabled);
+     }
+     /**
+      * Protected constructor used by subclasses that are disabled by default.
+      *
+      * @param parent parent widget
+      * @param enabled if true assume enabled
+      * @param x column relative to parent
+      * @param y row relative to parent
+      * @param width width of widget
+      * @param height height of widget
+      */
+     protected TScrollableWidget(final TWidget parent, final boolean enabled,
+         final int x, final int y, final int width, final int height) {
+         super(parent, enabled, x, y, width, height);
+     }
+     // ------------------------------------------------------------------------
+     // TWidget ----------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Handle window/screen resize events.
+      *
+      * @param event resize event
+      */
+     @Override
+     public void onResize(final TResizeEvent event) {
+         if (event.getType() == TResizeEvent.Type.WIDGET) {
+             setWidth(event.getWidth());
+             setHeight(event.getHeight());
+             reflowData();
+             placeScrollbars();
+             return;
+         } else {
+             super.onResize(event);
+         }
+     }
+     // ------------------------------------------------------------------------
+     // TScrollableWidget ------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Place the scrollbars on the edge of this widget, and adjust bigChange
+      * to match the new size.  This is called by onResize().
+      */
+     protected void placeScrollbars() {
+         if (hScroller != null) {
+             hScroller.setY(getHeight() - 1);
+             hScroller.setWidth(getWidth() - 1);
+             hScroller.setBigChange(getWidth() - 1);
+         }
+         if (vScroller != null) {
+             vScroller.setX(getWidth() - 1);
+             vScroller.setHeight(getHeight() - 1);
+             vScroller.setBigChange(getHeight() - 1);
+         }
+     }
+     /**
+      * Recompute whatever data is displayed by this widget.
+      */
+     public void reflowData() {
+         // Default: nothing to do
+     }
+     /**
+      * Get the horizontal scrollbar, or null if this Viewport does not
+      * support horizontal scrolling.
+      *
+      * @return the horizontal scrollbar
+      */
+     public THScroller getHorizontalScroller() {
+         return hScroller;
+     }
+     /**
+      * Get the vertical scrollbar, or null if this Viewport does not support
+      * vertical scrolling.
+      *
+      * @return the vertical scrollbar
+      */
+     public TVScroller getVerticalScroller() {
+         return vScroller;
+     }
+     /**
+      * Get the value that corresponds to being on the top edge of the
+      * vertical scroll bar.
+      *
+      * @return the scroll value
+      */
+     public int getTopValue() {
+         if (vScroller == null) {
+             return 0;
+         } else {
+             return vScroller.getTopValue();
+         }
+     }
+     /**
+      * Set the value that corresponds to being on the top edge of the
+      * vertical scroll bar.
+      *
+      * @param topValue the new scroll value
+      */
+     public void setTopValue(final int topValue) {
+         if (vScroller == null) {
+             return;
+         } else {
+             vScroller.setTopValue(topValue);
+         }
+     }
+     /**
+      * Get the value that corresponds to being on the bottom edge of the
+      * vertical scroll bar.
+      *
+      * @return the scroll value
+      */
+     public int getBottomValue() {
+         if (vScroller == null) {
+             return 0;
+         } else {
+             return vScroller.getBottomValue();
+         }
+     }
+     /**
+      * Set the value that corresponds to being on the bottom edge of the
+      * vertical scroll bar.
+      *
+      * @param bottomValue the new scroll value
+      */
+     public void setBottomValue(final int bottomValue) {
+         if (vScroller == null) {
+             return;
+         } else {
+             vScroller.setBottomValue(bottomValue);
+         }
+     }
+     /**
+      * Get current value of the vertical scroll.
+      *
+      * @return the scroll value
+      */
+     public int getVerticalValue() {
+         if (vScroller == null) {
+             return 0;
+         } else {
+             return vScroller.getValue();
+         }
+     }
+     /**
+      * Set current value of the vertical scroll.
+      *
+      * @param value the new scroll value
+      */
+     public void setVerticalValue(final int value) {
+         if (vScroller == null) {
+             return;
+         } else {
+             vScroller.setValue(value);
+         }
+     }
+     /**
+      * Get the increment for clicking on an arrow on the vertical scrollbar.
+      *
+      * @return the increment value
+      */
+     public int getVerticalSmallChange() {
+         if (vScroller == null) {
+             return 0;
+         } else {
+             return vScroller.getSmallChange();
+         }
+     }
+     /**
+      * Set the increment for clicking on an arrow on the vertical scrollbar.
+      *
+      * @param smallChange the new increment value
+      */
+     public void setVerticalSmallChange(final int smallChange) {
+         if (vScroller == null) {
+             return;
+         } else {
+             vScroller.setSmallChange(smallChange);
+         }
+     }
+     /**
+      * Get the increment for clicking in the bar between the box and an
+      * arrow on the vertical scrollbar.
+      *
+      * @return the increment value
+      */
+     public int getVerticalBigChange() {
+         if (vScroller == null) {
+             return 0;
+         } else {
+             return vScroller.getBigChange();
+         }
+     }
+     /**
+      * Set the increment for clicking in the bar between the box and an
+      * arrow on the vertical scrollbar.
+      *
+      * @param bigChange the new increment value
+      */
+     public void setVerticalBigChange(final int bigChange) {
+         if (vScroller == null) {
+             return;
+         } else {
+             vScroller.setBigChange(bigChange);
+         }
+     }
+     /**
+      * Perform a small step change up.
+      */
+     public void verticalDecrement() {
+         if (vScroller == null) {
+             return;
+         } else {
+             vScroller.decrement();
+         }
+     }
+     /**
+      * Perform a small step change down.
+      */
+     public void verticalIncrement() {
+         if (vScroller == null) {
+             return;
+         } else {
+             vScroller.increment();
+         }
+     }
+     /**
+      * Perform a big step change up.
+      */
+     public void bigVerticalDecrement() {
+         if (vScroller == null) {
+             return;
+         } else {
+             vScroller.bigDecrement();
+         }
+     }
+     /**
+      * Perform a big step change down.
+      */
+     public void bigVerticalIncrement() {
+         if (vScroller == null) {
+             return;
+         } else {
+             vScroller.bigIncrement();
+         }
+     }
+     /**
+      * Go to the top edge of the vertical scroller.
+      */
+     public void toTop() {
+         if (vScroller == null) {
+             return;
+         } else {
+             vScroller.toTop();
+         }
+     }
+     /**
+      * Go to the bottom edge of the vertical scroller.
+      */
+     public void toBottom() {
+         if (vScroller == null) {
+             return;
+         } else {
+             vScroller.toBottom();
+         }
+     }
+     /**
+      * Get the value that corresponds to being on the left edge of the
+      * horizontal scroll bar.
+      *
+      * @return the scroll value
+      */
+     public int getLeftValue() {
+         if (hScroller == null) {
+             return 0;
+         } else {
+             return hScroller.getLeftValue();
+         }
+     }
+     /**
+      * Set the value that corresponds to being on the left edge of the
+      * horizontal scroll bar.
+      *
+      * @param leftValue the new scroll value
+      */
+     public void setLeftValue(final int leftValue) {
+         if (hScroller == null) {
+             return;
+         } else {
+             hScroller.setLeftValue(leftValue);
+         }
+     }
+     /**
+      * Get the value that corresponds to being on the right edge of the
+      * horizontal scroll bar.
+      *
+      * @return the scroll value
+      */
+     public int getRightValue() {
+         if (hScroller == null) {
+             return 0;
+         } else {
+             return hScroller.getRightValue();
+         }
+     }
+     /**
+      * Set the value that corresponds to being on the right edge of the
+      * horizontal scroll bar.
+      *
+      * @param rightValue the new scroll value
+      */
+     public void setRightValue(final int rightValue) {
+         if (hScroller == null) {
+             return;
+         } else {
+             hScroller.setRightValue(rightValue);
+         }
+     }
+     /**
+      * Get current value of the horizontal scroll.
+      *
+      * @return the scroll value
+      */
+     public int getHorizontalValue() {
+         if (hScroller == null) {
+             return 0;
+         } else {
+             return hScroller.getValue();
+         }
+     }
+     /**
+      * Set current value of the horizontal scroll.
+      *
+      * @param value the new scroll value
+      */
+     public void setHorizontalValue(final int value) {
+         if (hScroller == null) {
+             return;
+         } else {
+             hScroller.setValue(value);
+         }
+     }
+     /**
+      * Get the increment for clicking on an arrow on the horizontal
+      * scrollbar.
+      *
+      * @return the increment value
+      */
+     public int getHorizontalSmallChange() {
+         if (hScroller == null) {
+             return 0;
+         } else {
+             return hScroller.getSmallChange();
+         }
+     }
+     /**
+      * Set the increment for clicking on an arrow on the horizontal
+      * scrollbar.
+      *
+      * @param smallChange the new increment value
+      */
+     public void setHorizontalSmallChange(final int smallChange) {
+         if (hScroller == null) {
+             return;
+         } else {
+             hScroller.setSmallChange(smallChange);
+         }
+     }
+     /**
+      * Get the increment for clicking in the bar between the box and an
+      * arrow on the horizontal scrollbar.
+      *
+      * @return the increment value
+      */
+     public int getHorizontalBigChange() {
+         if (hScroller == null) {
+             return 0;
+         } else {
+             return hScroller.getBigChange();
+         }
+     }
+     /**
+      * Set the increment for clicking in the bar between the box and an
+      * arrow on the horizontal scrollbar.
+      *
+      * @param bigChange the new increment value
+      */
+     public void setHorizontalBigChange(final int bigChange) {
+         if (hScroller == null) {
+             return;
+         } else {
+             hScroller.setBigChange(bigChange);
+         }
+     }
+     /**
+      * Perform a small step change left.
+      */
+     public void horizontalDecrement() {
+         if (hScroller == null) {
+             return;
+         } else {
+             hScroller.decrement();
+         }
+     }
+     /**
+      * Perform a small step change right.
+      */
+     public void horizontalIncrement() {
+         if (hScroller == null) {
+             return;
+         } else {
+             hScroller.increment();
+         }
+     }
+     /**
+      * Perform a big step change left.
+      */
+     public void bigHorizontalDecrement() {
+         if (hScroller == null) {
+             return;
+         } else {
+             hScroller.bigDecrement();
+         }
+     }
+     /**
+      * Perform a big step change right.
+      */
+     public void bigHorizontalIncrement() {
+         if (hScroller == null) {
+             return;
+         } else {
+             hScroller.bigIncrement();
+         }
+     }
+     /**
+      * Go to the left edge of the horizontal scroller.
+      */
+     public void toLeft() {
+         if (hScroller == null) {
+             return;
+         } else {
+             hScroller.toLeft();
+         }
+     }
+     /**
+      * Go to the right edge of the horizontal scroller.
+      */
+     public void toRight() {
+         if (hScroller == null) {
+             return;
+         } else {
+             hScroller.toRight();
+         }
+     }
+     /**
+      * Go to the top-left edge of the horizontal and vertical scrollers.
+      */
+     public void toHome() {
+         if (hScroller != null) {
+             hScroller.toLeft();
+         }
+         if (vScroller != null) {
+             vScroller.toTop();
+         }
+     }
+     /**
+      * Go to the bottom-right edge of the horizontal and vertical scrollers.
+      */
+     public void toEnd() {
+         if (hScroller != null) {
+             hScroller.toRight();
+         }
+         if (vScroller != null) {
+             vScroller.toBottom();
+         }
+     }
+ }
index 0000000000000000000000000000000000000000,1e260b3f86e00c7a7cc07409e28e0e1aa646fe03..1e260b3f86e00c7a7cc07409e28e0e1aa646fe03
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,680 +1,680 @@@
+ /*
+  * Jexer - Java Text User Interface
+  *
+  * The MIT License (MIT)
+  *
+  * 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"),
+  * to deal in the Software without restriction, including without limitation
+  * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+  * and/or sell copies of the Software, and to permit persons to whom the
+  * Software is furnished to do so, subject to the following conditions:
+  *
+  * The above copyright notice and this permission notice shall be included in
+  * all copies or substantial portions of the Software.
+  *
+  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+  * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+  * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+  * DEALINGS IN THE SOFTWARE.
+  *
+  * @author Kevin Lamonte [kevin.lamonte@gmail.com]
+  * @version 1
+  */
+ package jexer;
+ import jexer.event.TMouseEvent;
+ import jexer.event.TResizeEvent;
+ /**
+  * TScrollableWindow is a convenience superclass for windows that have
+  * scrollbars.
+  */
+ public class TScrollableWindow extends TWindow implements Scrollable {
+     // ------------------------------------------------------------------------
+     // Variables --------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * The horizontal scrollbar.
+      */
+     protected THScroller hScroller = null;
+     /**
+      * The vertical scrollbar.
+      */
+     protected TVScroller vScroller = null;
+     // ------------------------------------------------------------------------
+     // Constructors -----------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Public constructor.  Window will be located at (0, 0).
+      *
+      * @param application TApplication that manages this window
+      * @param title window title, will be centered along the top border
+      * @param width width of window
+      * @param height height of window
+      */
+     public TScrollableWindow(final TApplication application, final String title,
+         final int width, final int height) {
+         super(application, title, width, height);
+     }
+     /**
+      * Public constructor.  Window will be located at (0, 0).
+      *
+      * @param application TApplication that manages this window
+      * @param title window title, will be centered along the top border
+      * @param width width of window
+      * @param height height of window
+      * @param flags bitmask of RESIZABLE, CENTERED, or MODAL
+      */
+     public TScrollableWindow(final TApplication application, final String title,
+         final int width, final int height, final int flags) {
+         super(application, title, width, height, flags);
+     }
+     /**
+      * Public constructor.
+      *
+      * @param application TApplication that manages this window
+      * @param title window title, will be centered along the top border
+      * @param x column relative to parent
+      * @param y row relative to parent
+      * @param width width of window
+      * @param height height of window
+      */
+     public TScrollableWindow(final TApplication application, final String title,
+         final int x, final int y, final int width, final int height) {
+         super(application, title, x, y, width, height);
+     }
+     /**
+      * Public constructor.
+      *
+      * @param application TApplication that manages this window
+      * @param title window title, will be centered along the top border
+      * @param x column relative to parent
+      * @param y row relative to parent
+      * @param width width of window
+      * @param height height of window
+      * @param flags mask of RESIZABLE, CENTERED, or MODAL
+      */
+     public TScrollableWindow(final TApplication application, final String title,
+         final int x, final int y, final int width, final int height,
+         final int flags) {
+         super(application, title, x, y, width, height, flags);
+     }
+     // ------------------------------------------------------------------------
+     // TWindow ----------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Handle window/screen resize events.
+      *
+      * @param event resize event
+      */
+     @Override
+     public void onResize(final TResizeEvent event) {
+         if (event.getType() == TResizeEvent.Type.WIDGET) {
+             reflowData();
+             placeScrollbars();
+             return;
+         } else {
+             super.onResize(event);
+         }
+     }
+     /**
+      * Maximize window.
+      */
+     @Override
+     public void maximize() {
+         super.maximize();
+         placeScrollbars();
+     }
+     /**
+      * Restore (unmaximize) window.
+      */
+     @Override
+     public void restore() {
+         super.restore();
+         placeScrollbars();
+     }
+     // ------------------------------------------------------------------------
+     // TScrollableWindow ------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Place the scrollbars on the edge of this widget, and adjust bigChange
+      * to match the new size.  This is called by onResize().
+      */
+     protected void placeScrollbars() {
+         if (hScroller != null) {
+             hScroller.setX(Math.min(Math.max(0, getWidth() - 17), 17));
+             hScroller.setY(getHeight() - 2);
+             hScroller.setWidth(getWidth() - hScroller.getX() - 3);
+             hScroller.setBigChange(getWidth() - hScroller.getX() - 3);
+         }
+         if (vScroller != null) {
+             vScroller.setX(getWidth() - 2);
+             vScroller.setHeight(getHeight() - 2);
+             vScroller.setBigChange(getHeight() - 2);
+         }
+     }
+     /**
+      * Recompute whatever data is displayed by this widget.
+      */
+     public void reflowData() {
+         // Default: nothing to do
+     }
+     /**
+      * Get the horizontal scrollbar, or null if this Viewport does not
+      * support horizontal scrolling.
+      *
+      * @return the horizontal scrollbar
+      */
+     public THScroller getHorizontalScroller() {
+         return hScroller;
+     }
+     /**
+      * Get the vertical scrollbar, or null if this Viewport does not support
+      * vertical scrolling.
+      *
+      * @return the vertical scrollbar
+      */
+     public TVScroller getVerticalScroller() {
+         return vScroller;
+     }
+     /**
+      * Get the value that corresponds to being on the top edge of the
+      * vertical scroll bar.
+      *
+      * @return the scroll value
+      */
+     public int getTopValue() {
+         if (vScroller == null) {
+             return 0;
+         } else {
+             return vScroller.getTopValue();
+         }
+     }
+     /**
+      * Set the value that corresponds to being on the top edge of the
+      * vertical scroll bar.
+      *
+      * @param topValue the new scroll value
+      */
+     public void setTopValue(final int topValue) {
+         if (vScroller == null) {
+             return;
+         } else {
+             vScroller.setTopValue(topValue);
+         }
+     }
+     /**
+      * Get the value that corresponds to being on the bottom edge of the
+      * vertical scroll bar.
+      *
+      * @return the scroll value
+      */
+     public int getBottomValue() {
+         if (vScroller == null) {
+             return 0;
+         } else {
+             return vScroller.getBottomValue();
+         }
+     }
+     /**
+      * Set the value that corresponds to being on the bottom edge of the
+      * vertical scroll bar.
+      *
+      * @param bottomValue the new scroll value
+      */
+     public void setBottomValue(final int bottomValue) {
+         if (vScroller == null) {
+             return;
+         } else {
+             vScroller.setBottomValue(bottomValue);
+         }
+     }
+     /**
+      * Get current value of the vertical scroll.
+      *
+      * @return the scroll value
+      */
+     public int getVerticalValue() {
+         if (vScroller == null) {
+             return 0;
+         } else {
+             return vScroller.getValue();
+         }
+     }
+     /**
+      * Set current value of the vertical scroll.
+      *
+      * @param value the new scroll value
+      */
+     public void setVerticalValue(final int value) {
+         if (vScroller == null) {
+             return;
+         } else {
+             vScroller.setValue(value);
+         }
+     }
+     /**
+      * Get the increment for clicking on an arrow on the vertical scrollbar.
+      *
+      * @return the increment value
+      */
+     public int getVerticalSmallChange() {
+         if (vScroller == null) {
+             return 0;
+         } else {
+             return vScroller.getSmallChange();
+         }
+     }
+     /**
+      * Set the increment for clicking on an arrow on the vertical scrollbar.
+      *
+      * @param smallChange the new increment value
+      */
+     public void setVerticalSmallChange(final int smallChange) {
+         if (vScroller == null) {
+             return;
+         } else {
+             vScroller.setSmallChange(smallChange);
+         }
+     }
+     /**
+      * Get the increment for clicking in the bar between the box and an
+      * arrow on the vertical scrollbar.
+      *
+      * @return the increment value
+      */
+     public int getVerticalBigChange() {
+         if (vScroller == null) {
+             return 0;
+         } else {
+             return vScroller.getBigChange();
+         }
+     }
+     /**
+      * Set the increment for clicking in the bar between the box and an
+      * arrow on the vertical scrollbar.
+      *
+      * @param bigChange the new increment value
+      */
+     public void setVerticalBigChange(final int bigChange) {
+         if (vScroller == null) {
+             return;
+         } else {
+             vScroller.setBigChange(bigChange);
+         }
+     }
+     /**
+      * Perform a small step change up.
+      */
+     public void verticalDecrement() {
+         if (vScroller == null) {
+             return;
+         } else {
+             vScroller.decrement();
+         }
+     }
+     /**
+      * Perform a small step change down.
+      */
+     public void verticalIncrement() {
+         if (vScroller == null) {
+             return;
+         } else {
+             vScroller.increment();
+         }
+     }
+     /**
+      * Perform a big step change up.
+      */
+     public void bigVerticalDecrement() {
+         if (vScroller == null) {
+             return;
+         } else {
+             vScroller.bigDecrement();
+         }
+     }
+     /**
+      * Perform a big step change down.
+      */
+     public void bigVerticalIncrement() {
+         if (vScroller == null) {
+             return;
+         } else {
+             vScroller.bigIncrement();
+         }
+     }
+     /**
+      * Go to the top edge of the vertical scroller.
+      */
+     public void toTop() {
+         if (vScroller == null) {
+             return;
+         } else {
+             vScroller.toTop();
+         }
+     }
+     /**
+      * Go to the bottom edge of the vertical scroller.
+      */
+     public void toBottom() {
+         if (vScroller == null) {
+             return;
+         } else {
+             vScroller.toBottom();
+         }
+     }
+     /**
+      * Get the value that corresponds to being on the left edge of the
+      * horizontal scroll bar.
+      *
+      * @return the scroll value
+      */
+     public int getLeftValue() {
+         if (hScroller == null) {
+             return 0;
+         } else {
+             return hScroller.getLeftValue();
+         }
+     }
+     /**
+      * Set the value that corresponds to being on the left edge of the
+      * horizontal scroll bar.
+      *
+      * @param leftValue the new scroll value
+      */
+     public void setLeftValue(final int leftValue) {
+         if (hScroller == null) {
+             return;
+         } else {
+             hScroller.setLeftValue(leftValue);
+         }
+     }
+     /**
+      * Get the value that corresponds to being on the right edge of the
+      * horizontal scroll bar.
+      *
+      * @return the scroll value
+      */
+     public int getRightValue() {
+         if (hScroller == null) {
+             return 0;
+         } else {
+             return hScroller.getRightValue();
+         }
+     }
+     /**
+      * Set the value that corresponds to being on the right edge of the
+      * horizontal scroll bar.
+      *
+      * @param rightValue the new scroll value
+      */
+     public void setRightValue(final int rightValue) {
+         if (hScroller == null) {
+             return;
+         } else {
+             hScroller.setRightValue(rightValue);
+         }
+     }
+     /**
+      * Get current value of the horizontal scroll.
+      *
+      * @return the scroll value
+      */
+     public int getHorizontalValue() {
+         if (hScroller == null) {
+             return 0;
+         } else {
+             return hScroller.getValue();
+         }
+     }
+     /**
+      * Set current value of the horizontal scroll.
+      *
+      * @param value the new scroll value
+      */
+     public void setHorizontalValue(final int value) {
+         if (hScroller == null) {
+             return;
+         } else {
+             hScroller.setValue(value);
+         }
+     }
+     /**
+      * Get the increment for clicking on an arrow on the horizontal
+      * scrollbar.
+      *
+      * @return the increment value
+      */
+     public int getHorizontalSmallChange() {
+         if (hScroller == null) {
+             return 0;
+         } else {
+             return hScroller.getSmallChange();
+         }
+     }
+     /**
+      * Set the increment for clicking on an arrow on the horizontal
+      * scrollbar.
+      *
+      * @param smallChange the new increment value
+      */
+     public void setHorizontalSmallChange(final int smallChange) {
+         if (hScroller == null) {
+             return;
+         } else {
+             hScroller.setSmallChange(smallChange);
+         }
+     }
+     /**
+      * Get the increment for clicking in the bar between the box and an
+      * arrow on the horizontal scrollbar.
+      *
+      * @return the increment value
+      */
+     public int getHorizontalBigChange() {
+         if (hScroller == null) {
+             return 0;
+         } else {
+             return hScroller.getBigChange();
+         }
+     }
+     /**
+      * Set the increment for clicking in the bar between the box and an
+      * arrow on the horizontal scrollbar.
+      *
+      * @param bigChange the new increment value
+      */
+     public void setHorizontalBigChange(final int bigChange) {
+         if (hScroller == null) {
+             return;
+         } else {
+             hScroller.setBigChange(bigChange);
+         }
+     }
+     /**
+      * Perform a small step change left.
+      */
+     public void horizontalDecrement() {
+         if (hScroller == null) {
+             return;
+         } else {
+             hScroller.decrement();
+         }
+     }
+     /**
+      * Perform a small step change right.
+      */
+     public void horizontalIncrement() {
+         if (hScroller == null) {
+             return;
+         } else {
+             hScroller.increment();
+         }
+     }
+     /**
+      * Perform a big step change left.
+      */
+     public void bigHorizontalDecrement() {
+         if (hScroller == null) {
+             return;
+         } else {
+             hScroller.bigDecrement();
+         }
+     }
+     /**
+      * Perform a big step change right.
+      */
+     public void bigHorizontalIncrement() {
+         if (hScroller == null) {
+             return;
+         } else {
+             hScroller.bigIncrement();
+         }
+     }
+     /**
+      * Go to the left edge of the horizontal scroller.
+      */
+     public void toLeft() {
+         if (hScroller == null) {
+             return;
+         } else {
+             hScroller.toLeft();
+         }
+     }
+     /**
+      * Go to the right edge of the horizontal scroller.
+      */
+     public void toRight() {
+         if (hScroller == null) {
+             return;
+         } else {
+             hScroller.toRight();
+         }
+     }
+     /**
+      * Go to the top-left edge of the horizontal and vertical scrollers.
+      */
+     public void toHome() {
+         if (hScroller != null) {
+             hScroller.toLeft();
+         }
+         if (vScroller != null) {
+             vScroller.toTop();
+         }
+     }
+     /**
+      * Go to the bottom-right edge of the horizontal and vertical scrollers.
+      */
+     public void toEnd() {
+         if (hScroller != null) {
+             hScroller.toRight();
+         }
+         if (vScroller != null) {
+             vScroller.toBottom();
+         }
+     }
+     /**
+      * Check if a mouse press/release/motion event coordinate is over the
+      * vertical scrollbar.
+      *
+      * @param mouse a mouse-based event
+      * @return whether or not the mouse is on the scrollbar
+      */
+     protected final boolean mouseOnVerticalScroller(final TMouseEvent mouse) {
+         if (vScroller == null) {
+             return false;
+         }
+         if ((mouse.getAbsoluteX() == vScroller.getAbsoluteX())
+             && (mouse.getAbsoluteY() >= vScroller.getAbsoluteY())
+             && (mouse.getAbsoluteY() <  vScroller.getAbsoluteY() +
+                 vScroller.getHeight())
+         ) {
+             return true;
+         }
+         return false;
+     }
+     /**
+      * Check if a mouse press/release/motion event coordinate is over the
+      * horizontal scrollbar.
+      *
+      * @param mouse a mouse-based event
+      * @return whether or not the mouse is on the scrollbar
+      */
+     protected final boolean mouseOnHorizontalScroller(final TMouseEvent mouse) {
+         if (hScroller == null) {
+             return false;
+         }
+         if ((mouse.getAbsoluteY() == hScroller.getAbsoluteY())
+             && (mouse.getAbsoluteX() >= hScroller.getAbsoluteX())
+             && (mouse.getAbsoluteX() <  hScroller.getAbsoluteX() +
+                 hScroller.getWidth())
+         ) {
+             return true;
+         }
+         return false;
+     }
+ }
diff --combined src/jexer/TSpinner.java
index 0000000000000000000000000000000000000000,61fac65983ab4ddb9b106f14710747c00795ec62..61fac65983ab4ddb9b106f14710747c00795ec62
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,191 +1,191 @@@
+ /*
+  * Jexer - Java Text User Interface
+  *
+  * The MIT License (MIT)
+  *
+  * 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"),
+  * to deal in the Software without restriction, including without limitation
+  * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+  * and/or sell copies of the Software, and to permit persons to whom the
+  * Software is furnished to do so, subject to the following conditions:
+  *
+  * The above copyright notice and this permission notice shall be included in
+  * all copies or substantial portions of the Software.
+  *
+  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+  * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+  * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+  * DEALINGS IN THE SOFTWARE.
+  *
+  * @author Kevin Lamonte [kevin.lamonte@gmail.com]
+  * @version 1
+  */
+ package jexer;
+ import jexer.bits.CellAttributes;
+ import jexer.bits.GraphicsChars;
+ import jexer.event.TKeypressEvent;
+ import jexer.event.TMouseEvent;
+ import static jexer.TKeypress.*;
+ /**
+  * TSpinner implements a simple up/down spinner.
+  */
+ public class TSpinner extends TWidget {
+     // ------------------------------------------------------------------------
+     // Variables --------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * The action to perform when the user clicks on the up arrow.
+      */
+     private TAction upAction = null;
+     /**
+      * The action to perform when the user clicks on the down arrow.
+      */
+     private TAction downAction = null;
+     // ------------------------------------------------------------------------
+     // Constructors -----------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Public constructor.
+      *
+      * @param parent parent widget
+      * @param x column relative to parent
+      * @param y row relative to parent
+      * @param upAction action to call when the up arrow is clicked or pressed
+      * @param downAction action to call when the down arrow is clicked or
+      * pressed
+      */
+     public TSpinner(final TWidget parent, final int x, final int y,
+         final TAction upAction, final TAction downAction) {
+         // Set parent and window
+         super(parent, x, y, 2, 1);
+         this.upAction = upAction;
+         this.downAction = downAction;
+     }
+     // ------------------------------------------------------------------------
+     // Event handlers ---------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Returns true if the mouse is currently on the up arrow.
+      *
+      * @param mouse mouse event
+      * @return true if the mouse is currently on the up arrow
+      */
+     private boolean mouseOnUpArrow(final TMouseEvent mouse) {
+         if ((mouse.getY() == 0)
+             && (mouse.getX() == getWidth() - 2)
+         ) {
+             return true;
+         }
+         return false;
+     }
+     /**
+      * Returns true if the mouse is currently on the down arrow.
+      *
+      * @param mouse mouse event
+      * @return true if the mouse is currently on the down arrow
+      */
+     private boolean mouseOnDownArrow(final TMouseEvent mouse) {
+         if ((mouse.getY() == 0)
+             && (mouse.getX() == getWidth() - 1)
+         ) {
+             return true;
+         }
+         return false;
+     }
+     /**
+      * Handle mouse checkbox presses.
+      *
+      * @param mouse mouse button down event
+      */
+     @Override
+     public void onMouseDown(final TMouseEvent mouse) {
+         if ((mouseOnUpArrow(mouse)) && (mouse.isMouse1())) {
+             up();
+         } else if ((mouseOnDownArrow(mouse)) && (mouse.isMouse1())) {
+             down();
+         }
+     }
+     /**
+      * Handle keystrokes.
+      *
+      * @param keypress keystroke event
+      */
+     @Override
+     public void onKeypress(final TKeypressEvent keypress) {
+         if (keypress.equals(kbUp)) {
+             up();
+             return;
+         }
+         if (keypress.equals(kbDown)) {
+             down();
+             return;
+         }
+         // Pass to parent for the things we don't care about.
+         super.onKeypress(keypress);
+     }
+     // ------------------------------------------------------------------------
+     // TWidget ----------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Draw the spinner arrows.
+      */
+     @Override
+     public void draw() {
+         CellAttributes spinnerColor;
+         if (isAbsoluteActive()) {
+             spinnerColor = getTheme().getColor("tspinner.active");
+         } else {
+             spinnerColor = getTheme().getColor("tspinner.inactive");
+         }
+         putCharXY(getWidth() - 2, 0, GraphicsChars.UPARROW, spinnerColor);
+         putCharXY(getWidth() - 1, 0, GraphicsChars.DOWNARROW, spinnerColor);
+     }
+     // ------------------------------------------------------------------------
+     // TSpinner ---------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Perform the "up" action.
+      */
+     private void up() {
+         if (upAction != null) {
+             upAction.DO(this);
+         }
+     }
+     /**
+      * Perform the "down" action.
+      */
+     private void down() {
+         if (downAction != null) {
+             downAction.DO(this);
+         }
+     }
+ }
index 0000000000000000000000000000000000000000,7c85278f88d0d6df3ff34f01064e2be57d25481d..7c85278f88d0d6df3ff34f01064e2be57d25481d
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,602 +1,602 @@@
+ /*
+  * Jexer - Java Text User Interface
+  *
+  * The MIT License (MIT)
+  *
+  * 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"),
+  * to deal in the Software without restriction, including without limitation
+  * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+  * and/or sell copies of the Software, and to permit persons to whom the
+  * Software is furnished to do so, subject to the following conditions:
+  *
+  * The above copyright notice and this permission notice shall be included in
+  * all copies or substantial portions of the Software.
+  *
+  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+  * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+  * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+  * DEALINGS IN THE SOFTWARE.
+  *
+  * @author Kevin Lamonte [kevin.lamonte@gmail.com]
+  * @version 1
+  */
+ package jexer;
+ import jexer.bits.CellAttributes;
+ import jexer.bits.GraphicsChars;
+ import jexer.event.TMenuEvent;
+ import jexer.event.TMouseEvent;
+ import jexer.event.TResizeEvent;
+ import jexer.menu.TMenu;
+ /**
+  * TSplitPane contains two widgets with a draggable horizontal or vertical
+  * bar between them.
+  */
+ public class TSplitPane extends TWidget {
+     // ------------------------------------------------------------------------
+     // Variables --------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * If true, split vertically.  If false, split horizontally.
+      */
+     private boolean vertical = true;
+     /**
+      * The location of the split bar, either as a column number for vertical
+      * split or a row number for horizontal split.
+      */
+     private int split = 0;
+     /**
+      * The widget on the left side.
+      */
+     private TWidget left;
+     /**
+      * The widget on the right side.
+      */
+     private TWidget right;
+     /**
+      * The widget on the top side.
+      */
+     private TWidget top;
+     /**
+      * The widget on the bottom side.
+      */
+     private TWidget bottom;
+     /**
+      * If true, we are in the middle of a split move.
+      */
+     private boolean inSplitMove = false;
+     /**
+      * The last seen mouse position.
+      */
+     private TMouseEvent mouse;
+     // ------------------------------------------------------------------------
+     // Constructors -----------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Public constructor.
+      *
+      * @param parent parent widget
+      * @param x column relative to parent
+      * @param y row relative to parent
+      * @param width width of widget
+      * @param height height of widget
+      * @param vertical if true, split vertically
+      */
+     public TSplitPane(final TWidget parent, final int x, final int y,
+         final int width, final int height, final boolean vertical) {
+         super(parent, x, y, width, height);
+         this.vertical = vertical;
+         center();
+     }
+     // ------------------------------------------------------------------------
+     // Event handlers ---------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Handle window/screen resize events.
+      *
+      * @param event resize event
+      */
+     @Override
+     public void onResize(final TResizeEvent event) {
+         if (event.getType() == TResizeEvent.Type.WIDGET) {
+             // Resize me
+             super.onResize(event);
+             if (vertical && (split >= getWidth() - 2)) {
+                 center();
+             } else if (!vertical && (split >= getHeight() - 2)) {
+                 center();
+             } else {
+                 layoutChildren();
+             }
+         }
+     }
+     /**
+      * Handle mouse button presses.
+      *
+      * @param mouse mouse button event
+      */
+     @Override
+     public void onMouseDown(final TMouseEvent mouse) {
+         this.mouse = mouse;
+         inSplitMove = false;
+         if (mouse.isMouse1()) {
+             if (vertical) {
+                 inSplitMove = (mouse.getAbsoluteX() - getAbsoluteX() == split);
+             } else {
+                 inSplitMove = (mouse.getAbsoluteY() - getAbsoluteY() == split);
+             }
+             if (inSplitMove) {
+                 return;
+             }
+         }
+         // I didn't take it, pass it on to my children
+         super.onMouseDown(mouse);
+     }
+     /**
+      * Handle mouse button releases.
+      *
+      * @param mouse mouse button release event
+      */
+     @Override
+     public void onMouseUp(final TMouseEvent mouse) {
+         this.mouse = mouse;
+         if (inSplitMove && mouse.isMouse1()) {
+             // Stop moving split
+             inSplitMove = false;
+             return;
+         }
+         // I didn't take it, pass it on to my children
+         super.onMouseUp(mouse);
+     }
+     /**
+      * Handle mouse movements.
+      *
+      * @param mouse mouse motion event
+      */
+     @Override
+     public void onMouseMotion(final TMouseEvent mouse) {
+         this.mouse = mouse;
+         if ((mouse.getAbsoluteX() - getAbsoluteX() < 0)
+             || (mouse.getAbsoluteX() - getAbsoluteX() >= getWidth())
+             || (mouse.getAbsoluteY() - getAbsoluteY() < 0)
+             || (mouse.getAbsoluteY() - getAbsoluteY() >= getHeight())
+         ) {
+             // Mouse has travelled out of my window.
+             inSplitMove = false;
+         }
+         if (inSplitMove) {
+             if (vertical) {
+                 split = mouse.getAbsoluteX() - getAbsoluteX();
+                 split = Math.min(Math.max(1, split), getWidth() - 2);
+             } else {
+                 split = mouse.getAbsoluteY() - getAbsoluteY();
+                 split = Math.min(Math.max(1, split), getHeight() - 2);
+             }
+             layoutChildren();
+             return;
+         }
+         // I didn't take it, pass it on to my children
+         super.onMouseMotion(mouse);
+     }
+     // ------------------------------------------------------------------------
+     // TWidget ----------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Draw me on screen.
+      */
+     @Override
+     public void draw() {
+         CellAttributes attr = getTheme().getColor("tsplitpane");
+         if (vertical) {
+             vLineXY(split, 0, getHeight(), GraphicsChars.WINDOW_SIDE, attr);
+             // TODO: draw intersections of children
+             if ((mouse != null)
+                 && (mouse.getAbsoluteX() == getAbsoluteX() + split)
+                 && (mouse.getAbsoluteY() >= getAbsoluteY()) &&
+                 (mouse.getAbsoluteY() < getAbsoluteY() + getHeight())
+             ) {
+                 putCharXY(split, mouse.getAbsoluteY() - getAbsoluteY(),
+                     '\u2194', attr);
+             }
+         } else {
+             hLineXY(0, split, getWidth(), GraphicsChars.SINGLE_BAR, attr);
+             // TODO: draw intersections of children
+             if ((mouse != null)
+                 && (mouse.getAbsoluteY() == getAbsoluteY() + split)
+                 && (mouse.getAbsoluteX() >= getAbsoluteX()) &&
+                 (mouse.getAbsoluteX() < getAbsoluteX() + getWidth())
+             ) {
+                 putCharXY(mouse.getAbsoluteX() - getAbsoluteX(), split,
+                     '\u2195', attr);
+             }
+         }
+     }
+     /**
+      * Generate a human-readable string for this widget.
+      *
+      * @return a human-readable string
+      */
+     @Override
+     public String toString() {
+         return String.format("%s(%8x) %s position (%d, %d) geometry %dx%d " +
+             "split %d left %s(%8x) right %s(%8x) top %s(%8x) bottom %s(%8x) " +
+             "active %s enabled %s visible %s", getClass().getName(),
+             hashCode(), (vertical ? "VERTICAL" : "HORIZONTAL"),
+             getX(), getY(), getWidth(), getHeight(), split,
+             (left == null ? "null" : left.getClass().getName()),
+             (left == null ? 0 : left.hashCode()),
+             (right == null ? "null" : right.getClass().getName()),
+             (right == null ? 0 : right.hashCode()),
+             (top == null ? "null" : top.getClass().getName()),
+             (top == null ? 0 : top.hashCode()),
+             (bottom == null ? "null" : bottom.getClass().getName()),
+             (bottom == null ? 0 : bottom.hashCode()),
+             isActive(), isEnabled(), isVisible());
+     }
+     // ------------------------------------------------------------------------
+     // TSplitPane -------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Get the widget on the left side.
+      *
+      * @return the widget on the left, or null if not set
+      */
+     public TWidget getLeft() {
+         return left;
+     }
+     /**
+      * Set the widget on the left side.
+      *
+      * @param left the widget to set, or null to remove
+      */
+     public void setLeft(final TWidget left) {
+         if (!vertical) {
+             throw new IllegalArgumentException("cannot set left on " +
+                 "horizontal split pane");
+         }
+         if (left == null) {
+             if (this.left != null) {
+                 remove(this.left);
+             }
+             this.left = null;
+             return;
+         }
+         this.left = left;
+         left.setParent(this, false);
+         onResize(new TResizeEvent(TResizeEvent.Type.WIDGET, getWidth(),
+                 getHeight()));
+     }
+     /**
+      * Get the widget on the right side.
+      *
+      * @return the widget on the right, or null if not set
+      */
+     public TWidget getRight() {
+         return right;
+     }
+     /**
+      * Set the widget on the right side.
+      *
+      * @param right the widget to set, or null to remove
+      */
+     public void setRight(final TWidget right) {
+         if (!vertical) {
+             throw new IllegalArgumentException("cannot set right on " +
+                 "horizontal split pane");
+         }
+         if (right == null) {
+             if (this.right != null) {
+                 remove(this.right);
+             }
+             this.right = null;
+             return;
+         }
+         this.right = right;
+         right.setParent(this, false);
+         onResize(new TResizeEvent(TResizeEvent.Type.WIDGET, getWidth(),
+                 getHeight()));
+     }
+     /**
+      * Get the widget on the top side.
+      *
+      * @return the widget on the top, or null if not set
+      */
+     public TWidget getTop() {
+         return top;
+     }
+     /**
+      * Set the widget on the top side.
+      *
+      * @param top the widget to set, or null to remove
+      */
+     public void setTop(final TWidget top) {
+         if (vertical) {
+             throw new IllegalArgumentException("cannot set top on vertical " +
+                 "split pane");
+         }
+         if (top == null) {
+             if (this.top != null) {
+                 remove(this.top);
+             }
+             this.top = null;
+             return;
+         }
+         this.top = top;
+         top.setParent(this, false);
+         onResize(new TResizeEvent(TResizeEvent.Type.WIDGET, getWidth(),
+                 getHeight()));
+     }
+     /**
+      * Get the widget on the bottom side.
+      *
+      * @return the widget on the bottom, or null if not set
+      */
+     public TWidget getBottom() {
+         return bottom;
+     }
+     /**
+      * Set the widget on the bottom side.
+      *
+      * @param bottom the widget to set, or null to remove
+      */
+     public void setBottom(final TWidget bottom) {
+         if (vertical) {
+             throw new IllegalArgumentException("cannot set bottom on " +
+                 "vertical split pane");
+         }
+         if (bottom == null) {
+             if (this.bottom != null) {
+                 remove(this.bottom);
+             }
+             this.bottom = null;
+             return;
+         }
+         this.bottom = bottom;
+         bottom.setParent(this, false);
+         onResize(new TResizeEvent(TResizeEvent.Type.WIDGET, getWidth(),
+                 getHeight()));
+     }
+     /**
+      * Remove a widget, regardless of what pane it is on.
+      *
+      * @param widget the widget to remove
+      */
+     public void removeWidget(final TWidget widget) {
+         if (widget == null) {
+             throw new IllegalArgumentException("cannot remove null widget");
+         }
+         if (left == widget) {
+             left = null;
+             assert(right != widget);
+             assert(top != widget);
+             assert(bottom != widget);
+             return;
+         }
+         if (right == widget) {
+             right = null;
+             assert(left != widget);
+             assert(top != widget);
+             assert(bottom != widget);
+             return;
+         }
+         if (top == widget) {
+             top = null;
+             assert(left != widget);
+             assert(right != widget);
+             assert(bottom != widget);
+             return;
+         }
+         if (bottom == widget) {
+             bottom = null;
+             assert(left != widget);
+             assert(right != widget);
+             assert(top != widget);
+             return;
+         }
+         throw new IllegalArgumentException("widget " + widget +
+             " not in this split");
+     }
+     /**
+      * Replace a widget, regardless of what pane it is on, with another
+      * widget.
+      *
+      * @param oldWidget the widget to remove
+      * @param newWidget the widget to replace it with
+      */
+     public void replaceWidget(final TWidget oldWidget,
+         final TWidget newWidget) {
+         if (oldWidget == null) {
+             throw new IllegalArgumentException("cannot remove null oldWidget");
+         }
+         if (left == oldWidget) {
+             setLeft(newWidget);
+             assert(right != newWidget);
+             assert(top != newWidget);
+             assert(bottom != newWidget);
+             return;
+         }
+         if (right == oldWidget) {
+             setRight(newWidget);
+             assert(left != newWidget);
+             assert(top != newWidget);
+             assert(bottom != newWidget);
+             return;
+         }
+         if (top == oldWidget) {
+             setTop(newWidget);
+             assert(left != newWidget);
+             assert(right != newWidget);
+             assert(bottom != newWidget);
+             return;
+         }
+         if (bottom == oldWidget) {
+             setBottom(newWidget);
+             assert(left != newWidget);
+             assert(right != newWidget);
+             assert(top != newWidget);
+             return;
+         }
+         throw new IllegalArgumentException("oldWidget " + oldWidget +
+             " not in this split");
+     }
+     /**
+      * Layout the two child widgets.
+      */
+     private void layoutChildren() {
+         if (vertical) {
+             if (left != null) {
+                 left.setDimensions(0, 0, split, getHeight());
+                 left.onResize(new TResizeEvent(TResizeEvent.Type.WIDGET,
+                         left.getWidth(), left.getHeight()));
+             }
+             if (right != null) {
+                 right.setDimensions(split + 1, 0, getWidth() - split - 1,
+                     getHeight());
+                 right.onResize(new TResizeEvent(TResizeEvent.Type.WIDGET,
+                         right.getWidth(), right.getHeight()));
+             }
+         } else {
+             if (top != null) {
+                 top.setDimensions(0, 0, getWidth(), split);
+                 top.onResize(new TResizeEvent(TResizeEvent.Type.WIDGET,
+                         top.getWidth(), top.getHeight()));
+             }
+             if (bottom != null) {
+                 bottom.setDimensions(0, split + 1, getWidth(),
+                     getHeight() - split - 1);
+                 bottom.onResize(new TResizeEvent(TResizeEvent.Type.WIDGET,
+                         bottom.getWidth(), bottom.getHeight()));
+             }
+         }
+     }
+     /**
+      * Recenter the split to the middle of this split pane.
+      */
+     public void center() {
+         if (vertical) {
+             split = getWidth() / 2;
+         } else {
+             split = getHeight() / 2;
+         }
+         layoutChildren();
+     }
+     /**
+      * Remove this split, removing the widget specified.
+      *
+      * @param widgetToRemove the widget to remove
+      * @param doClose if true, call the close() method before removing the
+      * child
+      * @return the pane that remains, or null if nothing is retained
+      */
+     public TWidget removeSplit(final TWidget widgetToRemove,
+         final boolean doClose) {
+         TWidget keep = null;
+         if (vertical) {
+             if ((widgetToRemove != left) && (widgetToRemove != right)) {
+                 throw new IllegalArgumentException("widget to remove is not " +
+                     "either of the panes in this splitpane");
+             }
+             if (widgetToRemove == left) {
+                 keep = right;
+             } else {
+                 keep = left;
+             }
+         } else {
+             if ((widgetToRemove != top) && (widgetToRemove != bottom)) {
+                 throw new IllegalArgumentException("widget to remove is not " +
+                     "either of the panes in this splitpane");
+             }
+             if (widgetToRemove == top) {
+                 keep = bottom;
+             } else {
+                 keep = top;
+             }
+         }
+         // Remove me from my parent widget.
+         TWidget myParent = getParent();
+         remove(false);
+         if (keep == null) {
+             if (myParent instanceof TSplitPane) {
+                 // TSplitPane has a left/right/top/bottom link to me
+                 // somewhere, remove it.
+                 ((TSplitPane) myParent).removeWidget(this);
+             }
+             // Nothing is left of either pane.  Remove me and bail out.
+             return null;
+         }
+         if (myParent instanceof TSplitPane) {
+             // TSplitPane has a left/right/top/bottom link to me
+             // somewhere, replace me with keep.
+             ((TSplitPane) myParent).replaceWidget(this, keep);
+         } else {
+             keep.setParent(myParent, false);
+             keep.setDimensions(getX(), getY(), getWidth(), getHeight());
+             keep.onResize(new TResizeEvent(TResizeEvent.Type.WIDGET, getWidth(),
+                     getHeight()));
+         }
+         
+         return keep;
+     }
+ }
index 0000000000000000000000000000000000000000,fbd79da850c320f0fa3a7a54696d7da4ed31b857..fbd79da850c320f0fa3a7a54696d7da4ed31b857
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,329 +1,329 @@@
+ /*
+  * Jexer - Java Text User Interface
+  *
+  * The MIT License (MIT)
+  *
+  * 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"),
+  * to deal in the Software without restriction, including without limitation
+  * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+  * and/or sell copies of the Software, and to permit persons to whom the
+  * Software is furnished to do so, subject to the following conditions:
+  *
+  * The above copyright notice and this permission notice shall be included in
+  * all copies or substantial portions of the Software.
+  *
+  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+  * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+  * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+  * DEALINGS IN THE SOFTWARE.
+  *
+  * @author Kevin Lamonte [kevin.lamonte@gmail.com]
+  * @version 1
+  */
+ package jexer;
+ import java.util.ArrayList;
+ import java.util.List;
+ import jexer.bits.CellAttributes;
+ import jexer.bits.GraphicsChars;
+ import jexer.bits.StringUtils;
+ import jexer.event.TCommandEvent;
+ import jexer.event.TKeypressEvent;
+ import jexer.event.TMouseEvent;
+ /**
+  * TStatusBar implements a status line with clickable buttons.
+  */
+ public class TStatusBar extends TWidget {
+     // ------------------------------------------------------------------------
+     // Variables --------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Remember mouse state.
+      */
+     private TMouseEvent mouse;
+     /**
+      * The text to display on the right side of the shortcut keys.
+      */
+     private String text = null;
+     /**
+      * The shortcut keys.
+      */
+     private List<TStatusBarKey> keys = new ArrayList<TStatusBarKey>();
+     /**
+      * A single shortcut key.
+      */
+     private class TStatusBarKey {
+         /**
+          * The keypress for this action.
+          */
+         public TKeypress key;
+         /**
+          * The command to issue.
+          */
+         public TCommand cmd;
+         /**
+          * The label text.
+          */
+         public String label;
+         /**
+          * If true, the mouse is on this key.
+          */
+         public boolean selected;
+         /**
+          * The left edge coordinate to draw this key with.
+          */
+         public int x = 0;
+         /**
+          * The width of this key on the screen.
+          *
+          * @return the number of columns this takes when drawn
+          */
+         public int width() {
+             return StringUtils.width(this.label) +
+                 StringUtils.width(this.key.toString()) + 3;
+         }
+         /**
+          * Add a key to this status bar.
+          *
+          * @param key the key to trigger on
+          * @param cmd the command event to issue when key is pressed or this
+          * item is clicked
+          * @param label the label for this action
+          */
+         public TStatusBarKey(final TKeypress key, final TCommand cmd,
+             final String label) {
+             this.key    = key;
+             this.cmd    = cmd;
+             this.label  = label;
+         }
+     }
+     // ------------------------------------------------------------------------
+     // Constructors -----------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Public constructor.
+      *
+      * @param window the window associated with this status bar
+      * @param text text for the bar on the bottom row
+      */
+     public TStatusBar(final TWindow window, final String text) {
+         // TStatusBar is a parentless widget, because TApplication handles
+         // its drawing and event routing directly.
+         super(null, false, 0, 0, StringUtils.width(text), 1);
+         this.text = text;
+         setWindow(window);
+     }
+     /**
+      * Public constructor.
+      *
+      * @param window the window associated with this status bar
+      */
+     public TStatusBar(final TWindow window) {
+         this(window, "");
+     }
+     // ------------------------------------------------------------------------
+     // Event handlers ---------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Handle keypresses.
+      *
+      * @param keypress keystroke event
+      * @return true if this keypress was consumed
+      */
+     public boolean statusBarKeypress(final TKeypressEvent keypress) {
+         for (TStatusBarKey key: keys) {
+             if (keypress.equals(key.key)) {
+                 getApplication().postMenuEvent(new TCommandEvent(key.cmd));
+                 return true;
+             }
+         }
+         return false;
+     }
+     /**
+      * Returns true if the mouse is currently on the button.
+      *
+      * @param statusBarKey the status bar item
+      * @return if true the mouse is currently on the button
+      */
+     private boolean mouseOnShortcut(final TStatusBarKey statusBarKey) {
+         if ((mouse != null)
+             && (mouse.getAbsoluteY() == getApplication().getDesktopBottom())
+             && (mouse.getAbsoluteX() >= statusBarKey.x)
+             && (mouse.getAbsoluteX() < statusBarKey.x + statusBarKey.width())
+         ) {
+             return true;
+         }
+         return false;
+     }
+     /**
+      * Handle mouse button presses.
+      *
+      * @param mouse mouse button event
+      * @return true if this mouse event was consumed
+      */
+     public boolean statusBarMouseDown(final TMouseEvent mouse) {
+         this.mouse = mouse;
+         for (TStatusBarKey key: keys) {
+             if ((mouseOnShortcut(key)) && (mouse.isMouse1())) {
+                 key.selected = true;
+                 return true;
+             }
+         }
+         return false;
+     }
+     /**
+      * Handle mouse button releases.
+      *
+      * @param mouse mouse button release event
+      * @return true if this mouse event was consumed
+      */
+     public boolean statusBarMouseUp(final TMouseEvent mouse) {
+         this.mouse = mouse;
+         for (TStatusBarKey key: keys) {
+             if (key.selected && mouse.isMouse1()) {
+                 key.selected = false;
+                 // Dispatch the event
+                 getApplication().postMenuEvent(new TCommandEvent(key.cmd));
+                 return true;
+             }
+         }
+         return false;
+     }
+     /**
+      * Handle mouse movements.
+      *
+      * @param mouse mouse motion event
+      */
+     public void statusBarMouseMotion(final TMouseEvent mouse) {
+         this.mouse = mouse;
+         for (TStatusBarKey key: keys) {
+             if (!mouseOnShortcut(key)) {
+                 key.selected = false;
+             }
+         }
+     }
+     // ------------------------------------------------------------------------
+     // TWidget ----------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Draw the bar.
+      */
+     @Override
+     public void draw() {
+         CellAttributes barColor = new CellAttributes();
+         barColor.setTo(getTheme().getColor("tstatusbar.text"));
+         CellAttributes keyColor = new CellAttributes();
+         keyColor.setTo(getTheme().getColor("tstatusbar.button"));
+         CellAttributes selectedColor = new CellAttributes();
+         selectedColor.setTo(getTheme().getColor("tstatusbar.selected"));
+         // Status bar is weird.  Its draw() method is called directly by
+         // TApplication after everything is drawn, and after
+         // Screen.resetClipping().  So at this point we are drawing in
+         // absolute coordinates, not relative to our TWindow.
+         int row = getScreen().getHeight() - 1;
+         int width = getScreen().getWidth();
+         hLineXY(0, row, width, ' ', barColor);
+         int col = 0;
+         for (TStatusBarKey key: keys) {
+             String keyStr = key.key.toString();
+             if (key.selected) {
+                 putCharXY(col++, row, ' ', selectedColor);
+                 putStringXY(col, row, keyStr, selectedColor);
+                 col += StringUtils.width(keyStr);
+                 putCharXY(col++, row, ' ', selectedColor);
+                 putStringXY(col, row, key.label, selectedColor);
+                 col += StringUtils.width(key.label);
+                 putCharXY(col++, row, ' ', selectedColor);
+             } else {
+                 putCharXY(col++, row, ' ', barColor);
+                 putStringXY(col, row, keyStr, keyColor);
+                 col += StringUtils.width(keyStr) + 1;
+                 putStringXY(col, row, key.label, barColor);
+                 col += StringUtils.width(key.label);
+                 putCharXY(col++, row, ' ', barColor);
+             }
+         }
+         if (text.length() > 0) {
+             if (keys.size() > 0) {
+                 putCharXY(col++, row, GraphicsChars.VERTICAL_BAR, barColor);
+             }
+             putCharXY(col++, row, ' ', barColor);
+             putStringXY(col, row, text, barColor);
+         }
+     }
+     // ------------------------------------------------------------------------
+     // TStatusBar -------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Add a key to this status bar.
+      *
+      * @param key the key to trigger on
+      * @param cmd the command event to issue when key is pressed or this item
+      * is clicked
+      * @param label the label for this action
+      */
+     public void addShortcutKeypress(final TKeypress key, final TCommand cmd,
+         final String label) {
+         TStatusBarKey newKey = new TStatusBarKey(key, cmd, label);
+         if (keys.size() > 0) {
+             TStatusBarKey oldKey = keys.get(keys.size() - 1);
+             newKey.x = oldKey.x + oldKey.width();
+         }
+         keys.add(newKey);
+     }
+     /**
+      * Set the text to display on the right side of the shortcut keys.
+      *
+      * @param text the new text
+      */
+     public void setText(final String text) {
+         this.text = text;
+     }
+ }
index 0000000000000000000000000000000000000000,9b4d7c9847faaa6688dae4a65b63173169683e77..9b4d7c9847faaa6688dae4a65b63173169683e77
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,2361 +1,2361 @@@
+ /*
+  * Jexer - Java Text User Interface
+  *
+  * The MIT License (MIT)
+  *
+  * 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"),
+  * to deal in the Software without restriction, including without limitation
+  * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+  * and/or sell copies of the Software, and to permit persons to whom the
+  * Software is furnished to do so, subject to the following conditions:
+  *
+  * The above copyright notice and this permission notice shall be included in
+  * all copies or substantial portions of the Software.
+  *
+  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+  * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+  * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+  * DEALINGS IN THE SOFTWARE.
+  *
+  * @author Kevin Lamonte [kevin.lamonte@gmail.com]
+  * @version 1
+  */
+ package jexer;
+ import java.io.BufferedReader;
+ import java.io.BufferedWriter;
+ import java.io.File;
+ import java.io.FileReader;
+ import java.io.FileWriter;
+ import java.io.IOException;
+ import java.util.ArrayList;
+ import java.util.List;
+ import jexer.bits.CellAttributes;
+ import jexer.bits.StringUtils;
+ import jexer.event.TKeypressEvent;
+ import jexer.event.TMouseEvent;
+ import jexer.event.TResizeEvent;
+ import static jexer.TKeypress.*;
+ /**
+  * TTableWidget is used to display and edit regular two-dimensional tables of
+  * cells.
+  *
+  * This class was inspired by a TTable implementation originally developed by
+  * David "Niki" ROULET [niki@nikiroo.be], made available under MIT at
+  * https://github.com/nikiroo/jexer/tree/ttable_pull.
+  */
+ public class TTableWidget extends TWidget {
+     // ------------------------------------------------------------------------
+     // Constants --------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Available borders for cells.
+      */
+     public enum Border {
+         /**
+          * No border.
+          */
+         NONE,
+         /**
+          * Single bar: \u2502 (vertical) and \u2500 (horizontal).
+          */
+         SINGLE,
+         /**
+          * Double bar: \u2551 (vertical) and \u2550 (horizontal).
+          */
+         DOUBLE,
+         /**
+          * Thick bar: \u2503 (vertical heavy) and \u2501 (horizontal heavy).
+          */
+         THICK,
+     }
+     /**
+      * If true, put a grid of numbers in the cells.
+      */
+     private static final boolean DEBUG = false;
+     /**
+      * Row label width.
+      */
+     private static final int ROW_LABEL_WIDTH = 8;
+     /**
+      * Column label height.
+      */
+     private static final int COLUMN_LABEL_HEIGHT = 1;
+     /**
+      * Column default width.
+      */
+     private static final int COLUMN_DEFAULT_WIDTH = 8;
+     /**
+      * Extra rows to add.
+      */
+     private static final int EXTRA_ROWS = (DEBUG ? 10 : 0);
+     /**
+      * Extra columns to add.
+      */
+     private static final int EXTRA_COLUMNS = (DEBUG ? 3 : 0);
+     // ------------------------------------------------------------------------
+     // Variables --------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * The underlying data, organized as columns.
+      */
+     private ArrayList<Column> columns = new ArrayList<Column>();
+     /**
+      * The underlying data, organized as rows.
+      */
+     private ArrayList<Row> rows = new ArrayList<Row>();
+     /**
+      * The row in model corresponding to the top-left visible cell.
+      */
+     private int top = 0;
+     /**
+      * The column in model corresponding to the top-left visible cell.
+      */
+     private int left = 0;
+     /**
+      * The row in model corresponding to the currently selected cell.
+      */
+     private int selectedRow = 0;
+     /**
+      * The column in model corresponding to the currently selected cell.
+      */
+     private int selectedColumn = 0;
+     /**
+      * If true, highlight the entire row of the currently-selected cell.
+      */
+     private boolean highlightRow = false;
+     /**
+      * If true, highlight the entire column of the currently-selected cell.
+      */
+     private boolean highlightColumn = false;
+     /**
+      * If true, show the row labels as the first column.
+      */
+     private boolean showRowLabels = true;
+     /**
+      * If true, show the column labels as the first row.
+      */
+     private boolean showColumnLabels = true;
+     /**
+      * The top border for the first row.
+      */
+     private Border topBorder = Border.NONE;
+     /**
+      * The left border for the first column.
+      */
+     private Border leftBorder = Border.NONE;
+     /**
+      * Column represents a column of cells.
+      */
+     public class Column {
+         /**
+          * X position of this column.
+          */
+         private int x = 0;
+         /**
+          * Width of column.
+          */
+         private int width = COLUMN_DEFAULT_WIDTH;
+         /**
+          * The cells of this column.
+          */
+         private ArrayList<Cell> cells = new ArrayList<Cell>();
+         /**
+          * Column label.
+          */
+         private String label = "";
+         /**
+          * The right border for this column.
+          */
+         private Border rightBorder = Border.NONE;
+         /**
+          * Constructor sets label to lettered column.
+          *
+          * @param col column number to use for this column.  Column 0 will be
+          * "A", column 1 will be "B", column 26 will be "AA", and so on.
+          */
+         Column(int col) {
+             label = makeColumnLabel(col);
+         }
+         /**
+          * Add an entry to this column.
+          *
+          * @param cell the cell to add
+          */
+         public void add(final Cell cell) {
+             cells.add(cell);
+         }
+         /**
+          * Get an entry from this column.
+          *
+          * @param row the entry index to get
+          * @return the cell at row
+          */
+         public Cell get(final int row) {
+             return cells.get(row);
+         }
+         /**
+          * Get the X position of the cells in this column.
+          *
+          * @return the position
+          */
+         public int getX() {
+             return x;
+         }
+         /**
+          * Set the X position of the cells in this column.
+          *
+          * @param x the position
+          */
+         public void setX(final int x) {
+             for (Cell cell: cells) {
+                 cell.setX(x);
+             }
+             this.x = x;
+         }
+     }
+     /**
+      * Row represents a row of cells.
+      */
+     public class Row {
+         /**
+          * Y position of this row.
+          */
+         private int y = 0;
+         /**
+          * Height of row.
+          */
+         private int height = 1;
+         /**
+          * The cells of this row.
+          */
+         private ArrayList<Cell> cells = new ArrayList<Cell>();
+         /**
+          * Row label.
+          */
+         private String label = "";
+         /**
+          * The bottom border for this row.
+          */
+         private Border bottomBorder = Border.NONE;
+         /**
+          * Constructor sets label to numbered row.
+          *
+          * @param row row number to use for this row
+          */
+         Row(final int row) {
+             label = Integer.toString(row);
+         }
+         /**
+          * Add an entry to this column.
+          *
+          * @param cell the cell to add
+          */
+         public void add(final Cell cell) {
+             cells.add(cell);
+         }
+         /**
+          * Get an entry from this row.
+          *
+          * @param column the entry index to get
+          * @return the cell at column
+          */
+         public Cell get(final int column) {
+             return cells.get(column);
+         }
+         /**
+          * Get the Y position of the cells in this column.
+          *
+          * @return the position
+          */
+         public int getY() {
+             return y;
+         }
+         /**
+          * Set the Y position of the cells in this column.
+          *
+          * @param y the position
+          */
+         public void setY(final int y) {
+             for (Cell cell: cells) {
+                 cell.setY(y);
+             }
+             this.y = y;
+         }
+     }
+     /**
+      * Cell represents an editable cell in the table.  Normally, navigation
+      * to a cell only highlights it; pressing Enter or F2 will switch to
+      * editing mode.
+      */
+     public class Cell extends TWidget {
+         // --------------------------------------------------------------------
+         // Variables ----------------------------------------------------------
+         // --------------------------------------------------------------------
+         /**
+          * The field containing the cell's data.
+          */
+         private TField field;
+         /**
+          * The column of this cell.
+          */
+         private int column;
+         /**
+          * The row of this cell.
+          */
+         private int row;
+         /**
+          * If true, the cell is being edited.
+          */
+         private boolean isEditing = false;
+         /**
+          * If true, the cell is read-only (non-editable).
+          */
+         private boolean readOnly = false;
+         /**
+          * Text of field before editing.
+          */
+         private String fieldText;
+         // --------------------------------------------------------------------
+         // Constructors -------------------------------------------------------
+         // --------------------------------------------------------------------
+         /**
+          * Public constructor.
+          *
+          * @param parent parent widget
+          * @param x column relative to parent
+          * @param y row relative to parent
+          * @param width width of widget
+          * @param height height of widget
+          * @param column column index of this cell
+          * @param row row index of this cell
+          */
+         public Cell(final TTableWidget parent, final int x, final int y,
+             final int width, final int height, final int column,
+             final int row) {
+             super(parent, x, y, width, height);
+             this.column = column;
+             this.row = row;
+             field = addField(0, 0, width, false);
+             field.setEnabled(false);
+             field.setBackgroundChar(' ');
+         }
+         // --------------------------------------------------------------------
+         // Event handlers -----------------------------------------------------
+         // --------------------------------------------------------------------
+         /**
+          * Handle mouse double-click events.
+          *
+          * @param mouse mouse double-click event
+          */
+         @Override
+         public void onMouseDoubleClick(final TMouseEvent mouse) {
+             // Use TWidget's code to pass the event to the children.
+             super.onMouseDown(mouse);
+             // Double-click means to start editing.
+             fieldText = field.getText();
+             isEditing = true;
+             field.setEnabled(true);
+             activate(field);
+             if (isActive()) {
+                 // Let the table know that I was activated.
+                 ((TTableWidget) getParent()).selectedRow = row;
+                 ((TTableWidget) getParent()).selectedColumn = column;
+                 ((TTableWidget) getParent()).alignGrid();
+             }
+         }
+         /**
+          * Handle mouse press events.
+          *
+          * @param mouse mouse button press event
+          */
+         @Override
+         public void onMouseDown(final TMouseEvent mouse) {
+             // Use TWidget's code to pass the event to the children.
+             super.onMouseDown(mouse);
+             if (isActive()) {
+                 // Let the table know that I was activated.
+                 ((TTableWidget) getParent()).selectedRow = row;
+                 ((TTableWidget) getParent()).selectedColumn = column;
+                 ((TTableWidget) getParent()).alignGrid();
+             }
+         }
+         /**
+          * Handle mouse release events.
+          *
+          * @param mouse mouse button release event
+          */
+         @Override
+         public void onMouseUp(final TMouseEvent mouse) {
+             // Use TWidget's code to pass the event to the children.
+             super.onMouseDown(mouse);
+             if (isActive()) {
+                 // Let the table know that I was activated.
+                 ((TTableWidget) getParent()).selectedRow = row;
+                 ((TTableWidget) getParent()).selectedColumn = column;
+                 ((TTableWidget) getParent()).alignGrid();
+             }
+         }
+         /**
+          * Handle keystrokes.
+          *
+          * @param keypress keystroke event
+          */
+         @Override
+         public void onKeypress(final TKeypressEvent keypress) {
+             // System.err.println("Cell onKeypress: " + keypress);
+             if (readOnly) {
+                 // Read only: do nothing.
+                 return;
+             }
+             if (isEditing) {
+                 if (keypress.equals(kbEsc)) {
+                     // ESC cancels the edit.
+                     cancelEdit();
+                     return;
+                 }
+                 if (keypress.equals(kbEnter)) {
+                     // Enter ends editing.
+                     // Pass down to field first so that it can execute
+                     // enterAction if specified.
+                     super.onKeypress(keypress);
+                     fieldText = field.getText();
+                     isEditing = false;
+                     field.setEnabled(false);
+                     return;
+                 }
+                 // Pass down to field.
+                 super.onKeypress(keypress);
+             }
+             if (keypress.equals(kbEnter) || keypress.equals(kbF2)) {
+                 // Enter or F2 starts editing.
+                 fieldText = field.getText();
+                 isEditing = true;
+                 field.setEnabled(true);
+                 activate(field);
+                 return;
+             }
+         }
+         // --------------------------------------------------------------------
+         // TWidget ------------------------------------------------------------
+         // --------------------------------------------------------------------
+         /**
+          * Draw this cell.
+          */
+         @Override
+         public void draw() {
+             TTableWidget table = (TTableWidget) getParent();
+             if (isAbsoluteActive()) {
+                 if (isEditing) {
+                     field.setActiveColorKey("tfield.active");
+                     field.setInactiveColorKey("tfield.inactive");
+                 } else {
+                     field.setActiveColorKey("ttable.selected");
+                     field.setInactiveColorKey("ttable.selected");
+                 }
+             } else if (((table.selectedColumn == column)
+                     && ((table.selectedRow == row)
+                         || (table.highlightColumn == true)))
+                 || ((table.selectedRow == row)
+                     && ((table.selectedColumn == column)
+                         || (table.highlightRow == true)))
+             ) {
+                 field.setActiveColorKey("ttable.active");
+                 field.setInactiveColorKey("ttable.active");
+             } else {
+                 field.setActiveColorKey("ttable.active");
+                 field.setInactiveColorKey("ttable.inactive");
+             }
+             assert (isVisible() == true);
+             super.draw();
+         }
+         // --------------------------------------------------------------------
+         // TTable.Cell --------------------------------------------------------
+         // --------------------------------------------------------------------
+         /**
+          * Get field text.
+          *
+          * @return field text
+          */
+         public final String getText() {
+             return field.getText();
+         }
+         /**
+          * Set field text.
+          *
+          * @param text the new field text
+          */
+         public void setText(final String text) {
+             field.setText(text);
+         }
+         /**
+          * Cancel any pending edit.
+          */
+         public void cancelEdit() {
+             // Cancel any pending edit.
+             if (fieldText != null) {
+                 field.setText(fieldText);
+             }
+             isEditing = false;
+             field.setEnabled(false);
+         }
+         /**
+          * Set an entire column of cells read-only (non-editable) or not.
+          *
+          * @param readOnly if true, the cells will be non-editable
+          */
+         public void setReadOnly(final boolean readOnly) {
+             cancelEdit();
+             this.readOnly = readOnly;
+         }
+     }
+     // ------------------------------------------------------------------------
+     // Constructors -----------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Public constructor.
+      *
+      * @param parent parent widget
+      * @param x column relative to parent
+      * @param y row relative to parent
+      * @param width width of widget
+      * @param height height of widget
+      * @param gridColumns number of columns in grid
+      * @param gridRows number of rows in grid
+      */
+     public TTableWidget(final TWidget parent, final int x, final int y,
+         final int width, final int height, final int gridColumns,
+         final int gridRows) {
+         super(parent, x, y, width, height);
+         /*
+         System.err.println("gridColumns " + gridColumns +
+             " gridRows " + gridRows);
+          */
+         if (gridColumns < 1) {
+             throw new IllegalArgumentException("Column count cannot be less " +
+                 "than 1");
+         }
+         if (gridRows < 1) {
+             throw new IllegalArgumentException("Row count cannot be less " +
+                 "than 1");
+         }
+         // Initialize the starting row and column.
+         rows.add(new Row(0));
+         columns.add(new Column(0));
+         assert (rows.get(0).height == 1);
+         // Place a grid of cells that fit in this space.
+         for (int row = 0; row < gridRows; row++) {
+             for (int column = 0; column < gridColumns; column++) {
+                 Cell cell = new Cell(this, 0, 0, COLUMN_DEFAULT_WIDTH, 1,
+                     column, row);
+                 if (DEBUG) {
+                     // For debugging: set a grid of cell index labels.
+                     cell.setText("" + row + " " + column);
+                 }
+                 rows.get(row).add(cell);
+                 columns.get(column).add(cell);
+                 if (columns.size() < gridColumns) {
+                     columns.add(new Column(column + 1));
+                 }
+             }
+             if (row < gridRows - 1) {
+                 rows.add(new Row(row + 1));
+             }
+         }
+         for (int i = 0; i < rows.size(); i++) {
+             rows.get(i).setY(i + (showColumnLabels ? COLUMN_LABEL_HEIGHT : 0));
+         }
+         for (int j = 0; j < columns.size(); j++) {
+             columns.get(j).setX((j * (COLUMN_DEFAULT_WIDTH + 1)) +
+                 (showRowLabels ? ROW_LABEL_WIDTH : 0));
+         }
+         activate(columns.get(selectedColumn).get(selectedRow));
+         alignGrid();
+     }
+     /**
+      * Public constructor.
+      *
+      * @param parent parent widget
+      * @param x column relative to parent
+      * @param y row relative to parent
+      * @param width width of widget
+      * @param height height of widget
+      */
+     public TTableWidget(final TWidget parent, final int x, final int y,
+         final int width, final int height) {
+         this(parent, x, y, width, height,
+             width / (COLUMN_DEFAULT_WIDTH + 1) + EXTRA_COLUMNS,
+             height + EXTRA_ROWS);
+     }
+     // ------------------------------------------------------------------------
+     // Event handlers ---------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Handle mouse press events.
+      *
+      * @param mouse mouse button press event
+      */
+     @Override
+     public void onMouseDown(final TMouseEvent mouse) {
+         if (mouse.isMouseWheelUp() || mouse.isMouseWheelDown()) {
+             // Treat wheel up/down as 3 up/down
+             TKeypressEvent keyEvent;
+             if (mouse.isMouseWheelUp()) {
+                 keyEvent = new TKeypressEvent(kbUp);
+             } else {
+                 keyEvent = new TKeypressEvent(kbDown);
+             }
+             for (int i = 0; i < 3; i++) {
+                 onKeypress(keyEvent);
+             }
+             return;
+         }
+         // Use TWidget's code to pass the event to the children.
+         super.onMouseDown(mouse);
+     }
+     /**
+      * Handle keystrokes.
+      *
+      * @param keypress keystroke event
+      */
+     @Override
+     public void onKeypress(final TKeypressEvent keypress) {
+         if (keypress.equals(kbTab)
+             || keypress.equals(kbShiftTab)
+         ) {
+             // Squash tab and back-tab.  They don't make sense in the TTable
+             // grid context.
+             return;
+         }
+         // If editing, pass to that cell and do nothing else.
+         if (getSelectedCell().isEditing) {
+             super.onKeypress(keypress);
+             return;
+         }
+         if (keypress.equals(kbLeft)) {
+             // Left
+             if (selectedColumn > 0) {
+                 selectedColumn--;
+             }
+             activate(columns.get(selectedColumn).get(selectedRow));
+         } else if (keypress.equals(kbRight)) {
+             // Right
+             if (selectedColumn < columns.size() - 1) {
+                 selectedColumn++;
+             }
+             activate(columns.get(selectedColumn).get(selectedRow));
+         } else if (keypress.equals(kbUp)) {
+             // Up
+             if (selectedRow > 0) {
+                 selectedRow--;
+             }
+             activate(columns.get(selectedColumn).get(selectedRow));
+         } else if (keypress.equals(kbDown)) {
+             // Down
+             if (selectedRow < rows.size() - 1) {
+                 selectedRow++;
+             }
+             activate(columns.get(selectedColumn).get(selectedRow));
+         } else if (keypress.equals(kbHome)) {
+             // Home - leftmost column
+             selectedColumn = 0;
+             activate(columns.get(selectedColumn).get(selectedRow));
+         } else if (keypress.equals(kbEnd)) {
+             // End - rightmost column
+             selectedColumn = columns.size() - 1;
+             activate(columns.get(selectedColumn).get(selectedRow));
+         } else if (keypress.equals(kbPgUp)) {
+             // PgUp - Treat like multiple up
+             for (int i = 0; i < getHeight() - 2; i++) {
+                 if (selectedRow > 0) {
+                     selectedRow--;
+                 }
+             }
+             activate(columns.get(selectedColumn).get(selectedRow));
+         } else if (keypress.equals(kbPgDn)) {
+             // PgDn - Treat like multiple up
+             for (int i = 0; i < getHeight() - 2; i++) {
+                 if (selectedRow < rows.size() - 1) {
+                     selectedRow++;
+                 }
+             }
+             activate(columns.get(selectedColumn).get(selectedRow));
+         } else if (keypress.equals(kbCtrlHome)) {
+             // Ctrl-Home - go to top-left
+             selectedRow = 0;
+             selectedColumn = 0;
+             activate(columns.get(selectedColumn).get(selectedRow));
+             activate(columns.get(selectedColumn).get(selectedRow));
+         } else if (keypress.equals(kbCtrlEnd)) {
+             // Ctrl-End - go to bottom-right
+             selectedRow = rows.size() - 1;
+             selectedColumn = columns.size() - 1;
+             activate(columns.get(selectedColumn).get(selectedRow));
+             activate(columns.get(selectedColumn).get(selectedRow));
+         } else {
+             // Pass to the Cell.
+             super.onKeypress(keypress);
+         }
+         // We may have scrolled off screen.  Reset positions as needed to
+         // make the newly selected cell visible.
+         alignGrid();
+     }
+     /**
+      * Handle widget resize events.
+      *
+      * @param event resize event
+      */
+     @Override
+     public void onResize(final TResizeEvent event) {
+         super.onResize(event);
+         bottomRightCorner();
+     }
+     // ------------------------------------------------------------------------
+     // TWidget ----------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Draw the table row/column labels, and borders.
+      */
+     @Override
+     public void draw() {
+         CellAttributes labelColor = getTheme().getColor("ttable.label");
+         CellAttributes labelColorSelected = getTheme().getColor("ttable.label.selected");
+         CellAttributes borderColor = getTheme().getColor("ttable.border");
+         // Column labels.
+         if (showColumnLabels == true) {
+             for (int i = left; i < columns.size(); i++) {
+                 if (columns.get(i).get(top).isVisible() == false) {
+                     break;
+                 }
+                 putStringXY(columns.get(i).get(top).getX(), 0,
+                     String.format(" %-" +
+                         (columns.get(i).width - 2)
+                         + "s ", columns.get(i).label),
+                     (i == selectedColumn ? labelColorSelected : labelColor));
+             }
+         }
+         // Row labels.
+         if (showRowLabels == true) {
+             for (int i = top; i < rows.size(); i++) {
+                 if (rows.get(i).get(left).isVisible() == false) {
+                     break;
+                 }
+                 putStringXY(0, rows.get(i).get(left).getY(),
+                     String.format(" %-6s ", rows.get(i).label),
+                     (i == selectedRow ? labelColorSelected : labelColor));
+             }
+         }
+         // Draw vertical borders.
+         if (leftBorder == Border.SINGLE) {
+             vLineXY((showRowLabels ? ROW_LABEL_WIDTH : 0),
+                 (topBorder == Border.NONE ? 0 : 1) +
+                     (showColumnLabels ? COLUMN_LABEL_HEIGHT : 0),
+                 getHeight(), '\u2502', borderColor);
+         }
+         for (int i = left; i < columns.size(); i++) {
+             if (columns.get(i).get(top).isVisible() == false) {
+                 break;
+             }
+             if (columns.get(i).rightBorder == Border.SINGLE) {
+                 vLineXY(columns.get(i).getX() + columns.get(i).width,
+                     (topBorder == Border.NONE ? 0 : 1) +
+                         (showColumnLabels ? COLUMN_LABEL_HEIGHT : 0),
+                     getHeight(), '\u2502', borderColor);
+             }
+         }
+         // Draw horizontal borders.
+         if (topBorder == Border.SINGLE) {
+             hLineXY((showRowLabels ? ROW_LABEL_WIDTH : 0),
+                 (showColumnLabels ? COLUMN_LABEL_HEIGHT : 0),
+                 getWidth(), '\u2500', borderColor);
+         }
+         for (int i = top; i < rows.size(); i++) {
+             if (rows.get(i).get(left).isVisible() == false) {
+                 break;
+             }
+             if (rows.get(i).bottomBorder == Border.SINGLE) {
+                 hLineXY((leftBorder == Border.NONE ? 0 : 1) +
+                         (showRowLabels ? ROW_LABEL_WIDTH : 0),
+                     rows.get(i).getY() + rows.get(i).height - 1,
+                     getWidth(), '\u2500', borderColor);
+             } else if (rows.get(i).bottomBorder == Border.DOUBLE) {
+                 hLineXY((leftBorder == Border.NONE ? 0 : 1) +
+                         (showRowLabels ? ROW_LABEL_WIDTH : 0),
+                     rows.get(i).getY() + rows.get(i).height - 1,
+                     getWidth(), '\u2550', borderColor);
+             } else if (rows.get(i).bottomBorder == Border.THICK) {
+                 hLineXY((leftBorder == Border.NONE ? 0 : 1) +
+                         (showRowLabels ? ROW_LABEL_WIDTH : 0),
+                     rows.get(i).getY() + rows.get(i).height - 1,
+                     getWidth(), '\u2501', borderColor);
+             }
+         }
+         // Top-left corner if needed
+         if ((topBorder == Border.SINGLE) && (leftBorder == Border.SINGLE)) {
+             putCharXY((showRowLabels ? ROW_LABEL_WIDTH : 0),
+                 (showColumnLabels ? COLUMN_LABEL_HEIGHT : 0),
+                 '\u250c', borderColor);
+         }
+         // Now draw the correct corners
+         for (int i = top; i < rows.size(); i++) {
+             if (rows.get(i).get(left).isVisible() == false) {
+                 break;
+             }
+             for (int j = left; j < columns.size(); j++) {
+                 if (columns.get(j).get(i).isVisible() == false) {
+                     break;
+                 }
+                 if ((i == top) && (topBorder == Border.SINGLE)
+                     && (columns.get(j).rightBorder == Border.SINGLE)
+                 ) {
+                     // Top tee
+                     putCharXY(columns.get(j).getX() + columns.get(j).width,
+                         (showColumnLabels ? COLUMN_LABEL_HEIGHT : 0),
+                         '\u252c', borderColor);
+                 }
+                 if ((j == left) && (leftBorder == Border.SINGLE)
+                     && (rows.get(i).bottomBorder == Border.SINGLE)
+                 ) {
+                     // Left tee
+                     putCharXY((showRowLabels ? ROW_LABEL_WIDTH : 0),
+                         rows.get(i).getY() + rows.get(i).height - 1,
+                         '\u251c', borderColor);
+                 }
+                 if ((columns.get(j).rightBorder == Border.SINGLE)
+                     && (rows.get(i).bottomBorder == Border.SINGLE)
+                 ) {
+                     // Intersection of single bars
+                     putCharXY(columns.get(j).getX() + columns.get(j).width,
+                         rows.get(i).getY() + rows.get(i).height - 1,
+                         '\u253c', borderColor);
+                 }
+                 if ((j == left) && (leftBorder == Border.SINGLE)
+                     && (rows.get(i).bottomBorder == Border.DOUBLE)
+                 ) {
+                     // Left tee: single bar vertical, double bar horizontal
+                     putCharXY((showRowLabels ? ROW_LABEL_WIDTH : 0),
+                         rows.get(i).getY() + rows.get(i).height - 1,
+                         '\u255e', borderColor);
+                 }
+                 if ((j == left) && (leftBorder == Border.SINGLE)
+                     && (rows.get(i).bottomBorder == Border.THICK)
+                 ) {
+                     // Left tee: single bar vertical, thick bar horizontal
+                     putCharXY((showRowLabels ? ROW_LABEL_WIDTH : 0),
+                         rows.get(i).getY() + rows.get(i).height - 1,
+                         '\u251d', borderColor);
+                 }
+                 if ((columns.get(j).rightBorder == Border.SINGLE)
+                     && (rows.get(i).bottomBorder == Border.DOUBLE)
+                 ) {
+                     // Intersection: single bar vertical, double bar
+                     // horizontal
+                     putCharXY(columns.get(j).getX() + columns.get(j).width,
+                         rows.get(i).getY() + rows.get(i).height - 1,
+                         '\u256a', borderColor);
+                 }
+                 if ((columns.get(j).rightBorder == Border.SINGLE)
+                     && (rows.get(i).bottomBorder == Border.THICK)
+                 ) {
+                     // Intersection: single bar vertical, thick bar
+                     // horizontal
+                     putCharXY(columns.get(j).getX() + columns.get(j).width,
+                         rows.get(i).getY() + rows.get(i).height - 1,
+                         '\u253f', borderColor);
+                 }
+             }
+         }
+         // Now draw the window borders.
+         super.draw();
+     }
+     // ------------------------------------------------------------------------
+     // TTable -----------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Generate the default letter name for a column number.
+      *
+      * @param col column number to use for this column.  Column 0 will be
+      * "A", column 1 will be "B", column 26 will be "AA", and so on.
+      */
+     private String makeColumnLabel(int col) {
+         StringBuilder sb = new StringBuilder();
+         for (;;) {
+             sb.append((char) ('A' + (col % 26)));
+             if (col < 26) {
+                 break;
+             }
+             col /= 26;
+         }
+         return sb.reverse().toString();
+     }
+     /**
+      * Get the currently-selected cell.
+      *
+      * @return the selected cell
+      */
+     public Cell getSelectedCell() {
+         assert (rows.get(selectedRow) != null);
+         assert (rows.get(selectedRow).get(selectedColumn) != null);
+         assert (columns.get(selectedColumn) != null);
+         assert (columns.get(selectedColumn).get(selectedRow) != null);
+         assert (rows.get(selectedRow).get(selectedColumn) ==
+             columns.get(selectedColumn).get(selectedRow));
+         return (columns.get(selectedColumn).get(selectedRow));
+     }
+     /**
+      * Get the currently-selected column.
+      *
+      * @return the selected column
+      */
+     public Column getSelectedColumn() {
+         assert (selectedColumn >= 0);
+         assert (columns.size() > selectedColumn);
+         assert (columns.get(selectedColumn) != null);
+         return columns.get(selectedColumn);
+     }
+     /**
+      * Get the currently-selected row.
+      *
+      * @return the selected row
+      */
+     public Row getSelectedRow() {
+         assert (selectedRow >= 0);
+         assert (rows.size() > selectedRow);
+         assert (rows.get(selectedRow) != null);
+         return rows.get(selectedRow);
+     }
+     /**
+      * Get the currently-selected column number.  0 is the left-most column.
+      *
+      * @return the selected column number
+      */
+     public int getSelectedColumnNumber() {
+         return selectedColumn;
+     }
+     /**
+      * Set the currently-selected column number.  0 is the left-most column.
+      *
+      * @param column the column number to select
+      */
+     public void setSelectedColumnNumber(final int column) {
+         if ((column < 0) || (column > columns.size() - 1)) {
+             throw new IndexOutOfBoundsException("Column count is " +
+                 columns.size() + ", requested index " + column);
+         }
+         selectedColumn = column;
+         activate(columns.get(selectedColumn).get(selectedRow));
+         alignGrid();
+     }
+     /**
+      * Get the currently-selected row number.  0 is the top-most row.
+      *
+      * @return the selected row number
+      */
+     public int getSelectedRowNumber() {
+         return selectedRow;
+     }
+     /**
+      * Set the currently-selected row number.  0 is the left-most column.
+      *
+      * @param row the row number to select
+      */
+     public void setSelectedRowNumber(final int row) {
+         if ((row < 0) || (row > rows.size() - 1)) {
+             throw new IndexOutOfBoundsException("Row count is " +
+                 rows.size() + ", requested index " + row);
+         }
+         selectedRow = row;
+         activate(columns.get(selectedColumn).get(selectedRow));
+         alignGrid();
+     }
+     /**
+      * Get the highlight row flag.
+      *
+      * @return true if the selected row is highlighted
+      */
+     public boolean getHighlightRow() {
+         return highlightRow;
+     }
+     /**
+      * Set the highlight row flag.
+      *
+      * @param highlightRow if true, the selected row will be highlighted
+      */
+     public void setHighlightRow(final boolean highlightRow) {
+         this.highlightRow = highlightRow;
+     }
+     /**
+      * Get the highlight column flag.
+      *
+      * @return true if the selected column is highlighted
+      */
+     public boolean getHighlightColumn() {
+         return highlightColumn;
+     }
+     /**
+      * Set the highlight column flag.
+      *
+      * @param highlightColumn if true, the selected column will be highlighted
+      */
+     public void setHighlightColumn(final boolean highlightColumn) {
+         this.highlightColumn = highlightColumn;
+     }
+     /**
+      * Get the show row labels flag.
+      *
+      * @return true if row labels are shown
+      */
+     public boolean getShowRowLabels() {
+         return showRowLabels;
+     }
+     /**
+      * Set the show row labels flag.
+      *
+      * @param showRowLabels if true, the row labels will be shown
+      */
+     public void setShowRowLabels(final boolean showRowLabels) {
+         this.showRowLabels = showRowLabels;
+     }
+     /**
+      * Get the show column labels flag.
+      *
+      * @return true if column labels are shown
+      */
+     public boolean getShowColumnLabels() {
+         return showColumnLabels;
+     }
+     /**
+      * Set the show column labels flag.
+      *
+      * @param showColumnLabels if true, the column labels will be shown
+      */
+     public void setShowColumnLabels(final boolean showColumnLabels) {
+         this.showColumnLabels = showColumnLabels;
+     }
+     /**
+      * Get the number of columns.
+      *
+      * @return the number of columns
+      */
+     public int getColumnCount() {
+         return columns.size();
+     }
+     /**
+      * Get the number of rows.
+      *
+      * @return the number of rows
+      */
+     public int getRowCount() {
+         return rows.size();
+     }
+     /**
+      * Push top and left to the bottom-most right corner of the available
+      * grid.
+      */
+     private void bottomRightCorner() {
+         int viewColumns = getWidth();
+         if (showRowLabels == true) {
+             viewColumns -= ROW_LABEL_WIDTH;
+         }
+         // Set left and top such that the table stays on screen if possible.
+         top = rows.size() - getHeight();
+         left = columns.size() - (getWidth() / (viewColumns / (COLUMN_DEFAULT_WIDTH + 1)));
+         // Now ensure the selection is visible.
+         alignGrid();
+     }
+     /**
+      * Align the grid so that the selected cell is fully visible.
+      */
+     private void alignGrid() {
+         /*
+         System.err.println("alignGrid() # columns " + columns.size() +
+             " # rows " + rows.size());
+          */
+         int viewColumns = getWidth();
+         if (showRowLabels == true) {
+             viewColumns -= ROW_LABEL_WIDTH;
+         }
+         if (leftBorder != Border.NONE) {
+             viewColumns--;
+         }
+         int viewRows = getHeight();
+         if (showColumnLabels == true) {
+             viewRows -= COLUMN_LABEL_HEIGHT;
+         }
+         if (topBorder != Border.NONE) {
+             viewRows--;
+         }
+         // If we pushed left or right, adjust the box to include the new
+         // selected cell.
+         if (selectedColumn < left) {
+             left = selectedColumn - 1;
+         }
+         if (left < 0) {
+             left = 0;
+         }
+         if (selectedRow < top) {
+             top = selectedRow - 1;
+         }
+         if (top < 0) {
+             top = 0;
+         }
+         /*
+          * viewColumns and viewRows now contain the available columns and
+          * rows available to view the selected cell.  We adjust left and top
+          * to ensure the selected cell is within view, and then make all
+          * cells outside the box between (left, top) and (right, bottom)
+          * invisible.
+          *
+          * We need to calculate right and bottom now.
+          */
+         int right = left;
+         boolean done = false;
+         while (!done) {
+             int rightCellX = (showRowLabels ? ROW_LABEL_WIDTH : 0);
+             if (leftBorder != Border.NONE) {
+                 rightCellX++;
+             }
+             int maxCellX = rightCellX + viewColumns;
+             right = left;
+             boolean selectedIsVisible = false;
+             int selectedX = 0;
+             for (int x = left; x < columns.size(); x++) {
+                 if (x == selectedColumn) {
+                     selectedX = rightCellX;
+                     if (selectedX + columns.get(x).width + 1 <= maxCellX) {
+                         selectedIsVisible = true;
+                     }
+                 }
+                 rightCellX += columns.get(x).width + 1;
+                 if (rightCellX >= maxCellX) {
+                     break;
+                 }
+                 right++;
+             }
+             if (right < selectedColumn) {
+                 // selectedColumn is outside the view range.  Push left over,
+                 // and calculate again.
+                 left++;
+             } else if (left == selectedColumn) {
+                 // selectedColumn doesn't fit inside the view range, but we
+                 // can't go over any further either.  Bail out.
+                 done = true;
+             } else if (selectedIsVisible == false) {
+                 // selectedColumn doesn't fit inside the view range, continue
+                 // on.
+                 left++;
+             } else {
+                 // selectedColumn is fully visible, all done.
+                 assert (selectedIsVisible == true);
+                 done = true;
+             }
+         } // while (!done)
+         // We have the left/right range correct, set cell visibility and
+         // column X positions.
+         int leftCellX = showRowLabels ? ROW_LABEL_WIDTH : 0;
+         if (leftBorder != Border.NONE) {
+             leftCellX++;
+         }
+         for (int x = 0; x < columns.size(); x++) {
+             if ((x < left) || (x > right)) {
+                 for (int i = 0; i < rows.size(); i++) {
+                     columns.get(x).get(i).setVisible(false);
+                     columns.get(x).setX(getWidth() + 1);
+                 }
+                 continue;
+             }
+             for (int i = 0; i < rows.size(); i++) {
+                 columns.get(x).get(i).setVisible(true);
+             }
+             columns.get(x).setX(leftCellX);
+             leftCellX += columns.get(x).width + 1;
+         }
+         int bottom = top;
+         done = false;
+         while (!done) {
+             int bottomCellY = (showColumnLabels ? COLUMN_LABEL_HEIGHT : 0);
+             if (topBorder != Border.NONE) {
+                 bottomCellY++;
+             }
+             int maxCellY = bottomCellY + viewRows;
+             bottom = top;
+             for (int y = top; y < rows.size(); y++) {
+                 bottomCellY += rows.get(y).height;
+                 if (bottomCellY >= maxCellY) {
+                     break;
+                 }
+                 bottom++;
+             }
+             if (bottom < selectedRow) {
+                 // selectedRow is outside the view range.  Push top down, and
+                 // calculate again.
+                 top++;
+             } else {
+                 // selectedRow is inside the view range, done.
+                 done = true;
+             }
+         } // while (!done)
+         // We have the top/bottom range correct, set cell visibility and
+         // row Y positions.
+         int topCellY = showColumnLabels ? COLUMN_LABEL_HEIGHT : 0;
+         if (topBorder != Border.NONE) {
+             topCellY++;
+         }
+         for (int y = 0; y < rows.size(); y++) {
+             if ((y < top) || (y > bottom)) {
+                 for (int i = 0; i < columns.size(); i++) {
+                     rows.get(y).get(i).setVisible(false);
+                 }
+                 rows.get(y).setY(getHeight() + 1);
+                 continue;
+             }
+             for (int i = 0; i < columns.size(); i++) {
+                 rows.get(y).get(i).setVisible(true);
+             }
+             rows.get(y).setY(topCellY);
+             topCellY += rows.get(y).height;
+         }
+         // Last thing: cancel any edits that are not the selected cell.
+         for (int y = 0; y < rows.size(); y++) {
+             for (int x = 0; x < columns.size(); x++) {
+                 if ((x == selectedColumn) && (y == selectedRow)) {
+                     continue;
+                 }
+                 rows.get(y).get(x).cancelEdit();
+             }
+         }
+     }
+     /**
+      * Load contents from file in CSV format.
+      *
+      * @param csvFile a File referencing the CSV data
+      * @throws IOException if a java.io operation throws
+      */
+     public void loadCsvFile(final File csvFile) throws IOException {
+         BufferedReader reader = null;
+         try {
+             reader = new BufferedReader(new FileReader(csvFile));
+             String line = null;
+             boolean first = true;
+             for (line = reader.readLine(); line != null;
+                  line = reader.readLine()) {
+                 List<String> list = StringUtils.fromCsv(line);
+                 if (list.size() == 0) {
+                     continue;
+                 }
+                 if (list.size() > columns.size()) {
+                     int n = list.size() - columns.size();
+                     for (int i = 0; i < n; i++) {
+                         selectedColumn = columns.size() - 1;
+                         insertColumnRight(selectedColumn);
+                     }
+                 }
+                 assert (list.size() == columns.size());
+                 if (first) {
+                     // First row: just replace what is here.
+                     selectedRow = 0;
+                     first = false;
+                 } else {
+                     // All other rows: append to the end.
+                     selectedRow = rows.size() - 1;
+                     insertRowBelow(selectedRow);
+                     selectedRow = rows.size() - 1;
+                 }
+                 for (int i = 0; i < list.size(); i++) {
+                     rows.get(selectedRow).get(i).setText(list.get(i));
+                 }
+                 // TODO: detect header line
+             }
+         } finally {
+             if (reader != null) {
+                 reader.close();
+             }
+         }
+         left = 0;
+         top = 0;
+         selectedRow = 0;
+         selectedColumn = 0;
+         alignGrid();
+         activate(columns.get(selectedColumn).get(selectedRow));
+     }
+     /**
+      * Save contents to file in CSV format.
+      *
+      * @param filename file to save to
+      * @throws IOException if a java.io operation throws
+      */
+     public void saveToCsvFilename(final String filename) throws IOException {
+         BufferedWriter writer = null;
+         try {
+             writer = new BufferedWriter(new FileWriter(filename));
+             for (Row row: rows) {
+                 List<String> list = new ArrayList<String>(row.cells.size());
+                 for (Cell cell: row.cells) {
+                     list.add(cell.getText());
+                 }
+                 writer.write(StringUtils.toCsv(list));
+                 writer.write("\n");
+             }
+         } finally {
+             if (writer != null) {
+                 writer.close();
+             }
+         }
+     }
+     /**
+      * Save contents to file in text format with lines.
+      *
+      * @param filename file to save to
+      * @throws IOException if a java.io operation throws
+      */
+     public void saveToTextFilename(final String filename) throws IOException {
+         BufferedWriter writer = null;
+         try {
+             writer = new BufferedWriter(new FileWriter(filename));
+             if ((topBorder == Border.SINGLE) && (leftBorder == Border.SINGLE)) {
+                 // Emit top-left corner.
+                 writer.write("\u250c");
+             }
+             if (topBorder == Border.SINGLE) {
+                 int cellI = 0;
+                 for (Cell cell: rows.get(0).cells) {
+                     for (int i = 0; i < columns.get(cellI).width; i++) {
+                         writer.write("\u2500");
+                     }
+                     if (columns.get(cellI).rightBorder == Border.SINGLE) {
+                         if (cellI < columns.size() - 1) {
+                             // Emit top tee.
+                             writer.write("\u252c");
+                         } else {
+                             // Emit top-right corner.
+                             writer.write("\u2510");
+                         }
+                     }
+                     cellI++;
+                 }
+             }
+             writer.write("\n");
+             int rowI = 0;
+             for (Row row: rows) {
+                 if (leftBorder == Border.SINGLE) {
+                     // Emit left border.
+                     writer.write("\u2502");
+                 }
+                 int cellI = 0;
+                 for (Cell cell: row.cells) {
+                     writer.write(String.format("%" +
+                             columns.get(cellI).width + "s", cell.getText()));
+                     if (columns.get(cellI).rightBorder == Border.SINGLE) {
+                         // Emit right border.
+                         writer.write("\u2502");
+                     }
+                     cellI++;
+                 }
+                 writer.write("\n");
+                 if (row.bottomBorder == Border.NONE) {
+                     // All done, move on to the next row.
+                     continue;
+                 }
+                 // Emit the bottom borders and intersections.
+                 if ((leftBorder == Border.SINGLE)
+                     && (row.bottomBorder != Border.NONE)
+                 ) {
+                     if (rowI < rows.size() - 1) {
+                         if (row.bottomBorder == Border.SINGLE) {
+                             // Emit left tee.
+                             writer.write("\u251c");
+                         } else if (row.bottomBorder == Border.DOUBLE) {
+                             // Emit left tee (double).
+                             writer.write("\u255e");
+                         } else if (row.bottomBorder == Border.THICK) {
+                             // Emit left tee (thick).
+                             writer.write("\u251d");
+                         }
+                     }
+                     if (rowI == rows.size() - 1) {
+                         if (row.bottomBorder == Border.SINGLE) {
+                             // Emit left bottom corner.
+                             writer.write("\u2514");
+                         } else if (row.bottomBorder == Border.DOUBLE) {
+                             // Emit left bottom corner (double).
+                             writer.write("\u2558");
+                         } else if (row.bottomBorder == Border.THICK) {
+                             // Emit left bottom corner (thick).
+                             writer.write("\u2515");
+                         }
+                     }
+                 }
+                 cellI = 0;
+                 for (Cell cell: row.cells) {
+                     for (int i = 0; i < columns.get(cellI).width; i++) {
+                         if (row.bottomBorder == Border.SINGLE) {
+                             writer.write("\u2500");
+                         }
+                         if (row.bottomBorder == Border.DOUBLE) {
+                             writer.write("\u2550");
+                         }
+                         if (row.bottomBorder == Border.THICK) {
+                             writer.write("\u2501");
+                         }
+                     }
+                     if ((rowI < rows.size() - 1)
+                         && (cellI == columns.size() - 1)
+                         && (row.bottomBorder == Border.SINGLE)
+                         && (columns.get(cellI).rightBorder == Border.SINGLE)
+                     ) {
+                         // Emit right tee.
+                         writer.write("\u2524");
+                     }
+                     if ((rowI < rows.size() - 1)
+                         && (cellI == columns.size() - 1)
+                         && (row.bottomBorder == Border.DOUBLE)
+                         && (columns.get(cellI).rightBorder == Border.SINGLE)
+                     ) {
+                         // Emit right tee (double).
+                         writer.write("\u2561");
+                     }
+                     if ((rowI < rows.size() - 1)
+                         && (cellI == columns.size() - 1)
+                         && (row.bottomBorder == Border.THICK)
+                         && (columns.get(cellI).rightBorder == Border.SINGLE)
+                     ) {
+                         // Emit right tee (thick).
+                         writer.write("\u2525");
+                     }
+                     if ((rowI == rows.size() - 1)
+                         && (cellI == columns.size() - 1)
+                         && (row.bottomBorder == Border.SINGLE)
+                         && (columns.get(cellI).rightBorder == Border.SINGLE)
+                     ) {
+                         // Emit right bottom corner.
+                         writer.write("\u2518");
+                     }
+                     if ((rowI == rows.size() - 1)
+                         && (cellI == columns.size() - 1)
+                         && (row.bottomBorder == Border.DOUBLE)
+                         && (columns.get(cellI).rightBorder == Border.SINGLE)
+                     ) {
+                         // Emit right bottom corner (double).
+                         writer.write("\u255b");
+                     }
+                     if ((rowI == rows.size() - 1)
+                         && (cellI == columns.size() - 1)
+                         && (row.bottomBorder == Border.THICK)
+                         && (columns.get(cellI).rightBorder == Border.SINGLE)
+                     ) {
+                         // Emit right bottom corner (thick).
+                         writer.write("\u2519");
+                     }
+                     if ((rowI < rows.size() - 1)
+                         && (cellI < columns.size() - 1)
+                         && (row.bottomBorder == Border.SINGLE)
+                         && (columns.get(cellI).rightBorder == Border.SINGLE)
+                     ) {
+                         // Emit intersection.
+                         writer.write("\u253c");
+                     }
+                     if ((rowI < rows.size() - 1)
+                         && (cellI < columns.size() - 1)
+                         && (row.bottomBorder == Border.DOUBLE)
+                         && (columns.get(cellI).rightBorder == Border.SINGLE)
+                     ) {
+                         // Emit intersection (double).
+                         writer.write("\u256a");
+                     }
+                     if ((rowI < rows.size() - 1)
+                         && (cellI < columns.size() - 1)
+                         && (row.bottomBorder == Border.THICK)
+                         && (columns.get(cellI).rightBorder == Border.SINGLE)
+                     ) {
+                         // Emit intersection (thick).
+                         writer.write("\u253f");
+                     }
+                     if ((rowI == rows.size() - 1)
+                         && (cellI < columns.size() - 1)
+                         && (row.bottomBorder == Border.SINGLE)
+                         && (columns.get(cellI).rightBorder == Border.SINGLE)
+                     ) {
+                         // Emit bottom tee.
+                         writer.write("\u2534");
+                     }
+                     if ((rowI == rows.size() - 1)
+                         && (cellI < columns.size() - 1)
+                         && (row.bottomBorder == Border.DOUBLE)
+                         && (columns.get(cellI).rightBorder == Border.SINGLE)
+                     ) {
+                         // Emit bottom tee (double).
+                         writer.write("\u2567");
+                     }
+                     if ((rowI == rows.size() - 1)
+                         && (cellI < columns.size() - 1)
+                         && (row.bottomBorder == Border.THICK)
+                         && (columns.get(cellI).rightBorder == Border.SINGLE)
+                     ) {
+                         // Emit bottom tee (thick).
+                         writer.write("\u2537");
+                     }
+                     cellI++;
+                 }
+                 writer.write("\n");
+                 rowI++;
+             }
+         } finally {
+             if (writer != null) {
+                 writer.close();
+             }
+         }
+     }
+     /**
+      * Set the selected cell location.
+      *
+      * @param column the selected cell location column
+      * @param row the selected cell location row
+      */
+     public void setSelectedCell(final int column, final int row) {
+         if ((column < 0) || (column > columns.size() - 1)) {
+             throw new IndexOutOfBoundsException("Column count is " +
+                 columns.size() + ", requested index " + column);
+         }
+         if ((row < 0) || (row > rows.size() - 1)) {
+             throw new IndexOutOfBoundsException("Row count is " +
+                 rows.size() + ", requested index " + row);
+         }
+         selectedColumn = column;
+         selectedRow = row;
+         alignGrid();
+     }
+     /**
+      * Get a particular cell.
+      *
+      * @param column the cell column
+      * @param row the cell row
+      * @return the cell
+      */
+     public Cell getCell(final int column, final int row) {
+         if ((column < 0) || (column > columns.size() - 1)) {
+             throw new IndexOutOfBoundsException("Column count is " +
+                 columns.size() + ", requested index " + column);
+         }
+         if ((row < 0) || (row > rows.size() - 1)) {
+             throw new IndexOutOfBoundsException("Row count is " +
+                 rows.size() + ", requested index " + row);
+         }
+         return rows.get(row).get(column);
+     }
+     /**
+      * Get the text of a particular cell.
+      *
+      * @param column the cell column
+      * @param row the cell row
+      * @return the text in the cell
+      */
+     public String getCellText(final int column, final int row) {
+         if ((column < 0) || (column > columns.size() - 1)) {
+             throw new IndexOutOfBoundsException("Column count is " +
+                 columns.size() + ", requested index " + column);
+         }
+         if ((row < 0) || (row > rows.size() - 1)) {
+             throw new IndexOutOfBoundsException("Row count is " +
+                 rows.size() + ", requested index " + row);
+         }
+         return rows.get(row).get(column).getText();
+     }
+     /**
+      * Set the text of a particular cell.
+      *
+      * @param column the cell column
+      * @param row the cell row
+      * @param text the text to put into the cell
+      */
+     public void setCellText(final int column, final int row,
+         final String text) {
+         if ((column < 0) || (column > columns.size() - 1)) {
+             throw new IndexOutOfBoundsException("Column count is " +
+                 columns.size() + ", requested index " + column);
+         }
+         if ((row < 0) || (row > rows.size() - 1)) {
+             throw new IndexOutOfBoundsException("Row count is " +
+                 rows.size() + ", requested index " + row);
+         }
+         rows.get(row).get(column).setText(text);
+     }
+     /**
+      * Set the action to perform when the user presses enter on a particular
+      * cell.
+      *
+      * @param column the cell column
+      * @param row the cell row
+      * @param action the action to perform when the user presses enter on the
+      * cell
+      */
+     public void setCellEnterAction(final int column, final int row,
+         final TAction action) {
+         if ((column < 0) || (column > columns.size() - 1)) {
+             throw new IndexOutOfBoundsException("Column count is " +
+                 columns.size() + ", requested index " + column);
+         }
+         if ((row < 0) || (row > rows.size() - 1)) {
+             throw new IndexOutOfBoundsException("Row count is " +
+                 rows.size() + ", requested index " + row);
+         }
+         rows.get(row).get(column).field.setEnterAction(action);
+     }
+     /**
+      * Set the action to perform when the user updates a particular cell.
+      *
+      * @param column the cell column
+      * @param row the cell row
+      * @param action the action to perform when the user updates the cell
+      */
+     public void setCellUpdateAction(final int column, final int row,
+         final TAction action) {
+         if ((column < 0) || (column > columns.size() - 1)) {
+             throw new IndexOutOfBoundsException("Column count is " +
+                 columns.size() + ", requested index " + column);
+         }
+         if ((row < 0) || (row > rows.size() - 1)) {
+             throw new IndexOutOfBoundsException("Row count is " +
+                 rows.size() + ", requested index " + row);
+         }
+         rows.get(row).get(column).field.setUpdateAction(action);
+     }
+     /**
+      * Get the width of a column.
+      *
+      * @param column the column number
+      * @return the width of the column
+      */
+     public int getColumnWidth(final int column) {
+         if ((column < 0) || (column > columns.size() - 1)) {
+             throw new IndexOutOfBoundsException("Column count is " +
+                 columns.size() + ", requested index " + column);
+         }
+         return columns.get(column).width;
+     }
+     /**
+      * Set the width of a column.
+      *
+      * @param column the column number
+      * @param width the new width of the column
+      */
+     public void setColumnWidth(final int column, final int width) {
+         if ((column < 0) || (column > columns.size() - 1)) {
+             throw new IndexOutOfBoundsException("Column count is " +
+                 columns.size() + ", requested index " + column);
+         }
+         if (width < 4) {
+             // Columns may not be smaller than 4 cells wide.
+             return;
+         }
+         int delta = width - columns.get(column).width;
+         columns.get(column).width = width;
+         for (Cell cell: columns.get(column).cells) {
+             cell.setWidth(columns.get(column).width);
+             cell.field.setWidth(columns.get(column).width);
+         }
+         for (int i = column + 1; i < columns.size(); i++) {
+             columns.get(i).setX(columns.get(i).getX() + delta);
+         }
+         if (column == columns.size() - 1) {
+             bottomRightCorner();
+         } else {
+             alignGrid();
+         }
+     }
+     /**
+      * Get the label of a column.
+      *
+      * @param column the column number
+      * @return the label of the column
+      */
+     public String getColumnLabel(final int column) {
+         if ((column < 0) || (column > columns.size() - 1)) {
+             throw new IndexOutOfBoundsException("Column count is " +
+                 columns.size() + ", requested index " + column);
+         }
+         return columns.get(column).label;
+     }
+     /**
+      * Set the label of a column.
+      *
+      * @param column the column number
+      * @param label the new label of the column
+      */
+     public void setColumnLabel(final int column, final String label) {
+         if ((column < 0) || (column > columns.size() - 1)) {
+             throw new IndexOutOfBoundsException("Column count is " +
+                 columns.size() + ", requested index " + column);
+         }
+         columns.get(column).label = label;
+     }
+     /**
+      * Get the label of a row.
+      *
+      * @param row the row number
+      * @return the label of the row
+      */
+     public String getRowLabel(final int row) {
+         if ((row < 0) || (row > rows.size() - 1)) {
+             throw new IndexOutOfBoundsException("Row count is " +
+                 rows.size() + ", requested index " + row);
+         }
+         return rows.get(row).label;
+     }
+     /**
+      * Set the label of a row.
+      *
+      * @param row the row number
+      * @param label the new label of the row
+      */
+     public void setRowLabel(final int row, final String label) {
+         if ((row < 0) || (row > rows.size() - 1)) {
+             throw new IndexOutOfBoundsException("Row count is " +
+                 rows.size() + ", requested index " + row);
+         }
+         rows.get(row).label = label;
+     }
+     /**
+      * Insert one row at a particular index.
+      *
+      * @param idx the row number
+      */
+     private void insertRowAt(final int idx) {
+         Row newRow = new Row(idx);
+         for (int i = 0; i < columns.size(); i++) {
+             Cell cell = new Cell(this, columns.get(i).getX(),
+                 rows.get(idx).getY(), COLUMN_DEFAULT_WIDTH, 1, i, idx);
+             newRow.add(cell);
+             columns.get(i).cells.add(idx, cell);
+         }
+         rows.add(idx, newRow);
+         for (int x = 0; x < columns.size(); x++) {
+             for (int y = idx; y < rows.size(); y++) {
+                 columns.get(x).get(y).row = y;
+                 columns.get(x).get(y).column = x;
+             }
+         }
+         for (int i = idx + 1; i < rows.size(); i++) {
+             String oldRowLabel = Integer.toString(i - 1);
+             if (rows.get(i).label.equals(oldRowLabel)) {
+                 rows.get(i).label = Integer.toString(i);
+             }
+         }
+         alignGrid();
+     }
+     /**
+      * Insert one row above a particular row.
+      *
+      * @param row the row number
+      */
+     public void insertRowAbove(final int row) {
+         if ((row < 0) || (row > rows.size() - 1)) {
+             throw new IndexOutOfBoundsException("Row count is " +
+                 rows.size() + ", requested index " + row);
+         }
+         insertRowAt(row);
+         selectedRow++;
+         activate(columns.get(selectedColumn).get(selectedRow));
+     }
+     /**
+      * Insert one row below a particular row.
+      *
+      * @param row the row number
+      */
+     public void insertRowBelow(final int row) {
+         if ((row < 0) || (row > rows.size() - 1)) {
+             throw new IndexOutOfBoundsException("Row count is " +
+                 rows.size() + ", requested index " + row);
+         }
+         int idx = row + 1;
+         if (idx < rows.size()) {
+             insertRowAt(idx);
+             activate(columns.get(selectedColumn).get(selectedRow));
+             return;
+         }
+         // row is the last row, we need to perform an append.
+         Row newRow = new Row(idx);
+         for (int i = 0; i < columns.size(); i++) {
+             Cell cell = new Cell(this, columns.get(i).getX(),
+                 rows.get(row).getY(), COLUMN_DEFAULT_WIDTH, 1, i, idx);
+             newRow.add(cell);
+             columns.get(i).cells.add(cell);
+         }
+         rows.add(newRow);
+         alignGrid();
+         activate(columns.get(selectedColumn).get(selectedRow));
+     }
+     /**
+      * Delete a particular row.
+      *
+      * @param row the row number
+      */
+     public void deleteRow(final int row) {
+         if ((row < 0) || (row > rows.size() - 1)) {
+             throw new IndexOutOfBoundsException("Row count is " +
+                 rows.size() + ", requested index " + row);
+         }
+         if (rows.size() == 1) {
+             // Don't delete the last row.
+             return;
+         }
+         for (int i = 0; i < columns.size(); i++) {
+             Cell cell = columns.get(i).cells.remove(row);
+             getChildren().remove(cell);
+         }
+         rows.remove(row);
+         for (int x = 0; x < columns.size(); x++) {
+             for (int y = row; y < rows.size(); y++) {
+                 columns.get(x).get(y).row = y;
+                 columns.get(x).get(y).column = x;
+             }
+         }
+         for (int i = row; i < rows.size(); i++) {
+             String oldRowLabel = Integer.toString(i + 1);
+             if (rows.get(i).label.equals(oldRowLabel)) {
+                 rows.get(i).label = Integer.toString(i);
+             }
+         }
+         if (selectedRow == rows.size()) {
+             selectedRow--;
+         }
+         activate(columns.get(selectedColumn).get(selectedRow));
+         bottomRightCorner();
+     }
+     /**
+      * Insert one column at a particular index.
+      *
+      * @param idx the column number
+      */
+     private void insertColumnAt(final int idx) {
+         Column newColumn = new Column(idx);
+         for (int i = 0; i < rows.size(); i++) {
+             Cell cell = new Cell(this, columns.get(idx).getX(),
+                 rows.get(i).getY(), COLUMN_DEFAULT_WIDTH, 1, idx, i);
+             newColumn.add(cell);
+             rows.get(i).cells.add(idx, cell);
+         }
+         columns.add(idx, newColumn);
+         for (int x = idx; x < columns.size(); x++) {
+             for (int y = 0; y < rows.size(); y++) {
+                 columns.get(x).get(y).row = y;
+                 columns.get(x).get(y).column = x;
+             }
+         }
+         for (int i = idx + 1; i < columns.size(); i++) {
+             String oldColumnLabel = makeColumnLabel(i - 1);
+             if (columns.get(i).label.equals(oldColumnLabel)) {
+                 columns.get(i).label = makeColumnLabel(i);
+             }
+         }
+         alignGrid();
+     }
+     /**
+      * Insert one column to the left of a particular column.
+      *
+      * @param column the column number
+      */
+     public void insertColumnLeft(final int column) {
+         if ((column < 0) || (column > columns.size() - 1)) {
+             throw new IndexOutOfBoundsException("Column count is " +
+                 columns.size() + ", requested index " + column);
+         }
+         insertColumnAt(column);
+         selectedColumn++;
+         activate(columns.get(selectedColumn).get(selectedRow));
+     }
+     /**
+      * Insert one column to the right of a particular column.
+      *
+      * @param column the column number
+      */
+     public void insertColumnRight(final int column) {
+         if ((column < 0) || (column > columns.size() - 1)) {
+             throw new IndexOutOfBoundsException("Column count is " +
+                 columns.size() + ", requested index " + column);
+         }
+         int idx = column + 1;
+         if (idx < columns.size()) {
+             insertColumnAt(idx);
+             activate(columns.get(selectedColumn).get(selectedRow));
+             return;
+         }
+         // column is the last column, we need to perform an append.
+         Column newColumn = new Column(idx);
+         for (int i = 0; i < rows.size(); i++) {
+             Cell cell = new Cell(this, columns.get(column).getX(),
+                 rows.get(i).getY(), COLUMN_DEFAULT_WIDTH, 1, idx, i);
+             newColumn.add(cell);
+             rows.get(i).cells.add(cell);
+         }
+         columns.add(newColumn);
+         alignGrid();
+         activate(columns.get(selectedColumn).get(selectedRow));
+     }
+     /**
+      * Delete a particular column.
+      *
+      * @param column the column number
+      */
+     public void deleteColumn(final int column) {
+         if ((column < 0) || (column > columns.size() - 1)) {
+             throw new IndexOutOfBoundsException("Column count is " +
+                 columns.size() + ", requested index " + column);
+         }
+         if (columns.size() == 1) {
+             // Don't delete the last column.
+             return;
+         }
+         for (int i = 0; i < rows.size(); i++) {
+             Cell cell = rows.get(i).cells.remove(column);
+             getChildren().remove(cell);
+         }
+         columns.remove(column);
+         for (int x = column; x < columns.size(); x++) {
+             for (int y = 0; y < rows.size(); y++) {
+                 columns.get(x).get(y).row = y;
+                 columns.get(x).get(y).column = x;
+             }
+         }
+         for (int i = column; i < columns.size(); i++) {
+             String oldColumnLabel = makeColumnLabel(i + 1);
+             if (columns.get(i).label.equals(oldColumnLabel)) {
+                 columns.get(i).label = makeColumnLabel(i);
+             }
+         }
+         if (selectedColumn == columns.size()) {
+             selectedColumn--;
+         }
+         activate(columns.get(selectedColumn).get(selectedRow));
+         bottomRightCorner();
+     }
+     /**
+      * Delete the selected cell, shifting cells over to the left.
+      */
+     public void deleteCellShiftLeft() {
+         // All we do is copy the text from every cell in this row over.
+         for (int i = selectedColumn + 1; i < columns.size(); i++) {
+             setCellText(i - 1, selectedRow, getCellText(i, selectedRow));
+         }
+         setCellText(columns.size() - 1, selectedRow, "");
+     }
+     /**
+      * Delete the selected cell, shifting cells from below up.
+      */
+     public void deleteCellShiftUp() {
+         // All we do is copy the text from every cell in this column up.
+         for (int i = selectedRow + 1; i < rows.size(); i++) {
+             setCellText(selectedColumn, i - 1, getCellText(selectedColumn, i));
+         }
+         setCellText(selectedColumn, rows.size() - 1, "");
+     }
+     /**
+      * Set a particular cell read-only (non-editable) or not.
+      *
+      * @param column the cell column
+      * @param row the cell row
+      * @param readOnly if true, the cell will be non-editable
+      */
+     public void setCellReadOnly(final int column, final int row,
+         final boolean readOnly) {
+         if ((column < 0) || (column > columns.size() - 1)) {
+             throw new IndexOutOfBoundsException("Column count is " +
+                 columns.size() + ", requested index " + column);
+         }
+         if ((row < 0) || (row > rows.size() - 1)) {
+             throw new IndexOutOfBoundsException("Row count is " +
+                 rows.size() + ", requested index " + row);
+         }
+         rows.get(row).get(column).setReadOnly(readOnly);
+     }
+     /**
+      * Set an entire row of cells read-only (non-editable) or not.
+      *
+      * @param row the row number
+      * @param readOnly if true, the cells will be non-editable
+      */
+     public void setRowReadOnly(final int row, final boolean readOnly) {
+         if ((row < 0) || (row > rows.size() - 1)) {
+             throw new IndexOutOfBoundsException("Row count is " +
+                 rows.size() + ", requested index " + row);
+         }
+         for (Cell cell: rows.get(row).cells) {
+             cell.setReadOnly(readOnly);
+         }
+     }
+     /**
+      * Set an entire column of cells read-only (non-editable) or not.
+      *
+      * @param column the column number
+      * @param readOnly if true, the cells will be non-editable
+      */
+     public void setColumnReadOnly(final int column, final boolean readOnly) {
+         if ((column < 0) || (column > columns.size() - 1)) {
+             throw new IndexOutOfBoundsException("Column count is " +
+                 columns.size() + ", requested index " + column);
+         }
+         for (Cell cell: columns.get(column).cells) {
+             cell.setReadOnly(readOnly);
+         }
+     }
+     /**
+      * Set all borders across the entire table to Border.NONE.
+      */
+     public void setBorderAllNone() {
+         topBorder = Border.NONE;
+         leftBorder = Border.NONE;
+         for (int i = 0; i < columns.size(); i++) {
+             columns.get(i).rightBorder = Border.NONE;
+         }
+         for (int i = 0; i < rows.size(); i++) {
+             rows.get(i).bottomBorder = Border.NONE;
+             rows.get(i).height = 1;
+         }
+         bottomRightCorner();
+     }
+     /**
+      * Set all borders across the entire table to Border.SINGLE.
+      */
+     public void setBorderAllSingle() {
+         topBorder = Border.SINGLE;
+         leftBorder = Border.SINGLE;
+         for (int i = 0; i < columns.size(); i++) {
+             columns.get(i).rightBorder = Border.SINGLE;
+         }
+         for (int i = 0; i < rows.size(); i++) {
+             rows.get(i).bottomBorder = Border.SINGLE;
+             rows.get(i).height = 2;
+         }
+         alignGrid();
+     }
+     /**
+      * Set all borders around the selected cell to Border.NONE.
+      */
+     public void setBorderCellNone() {
+         if (selectedRow == 0) {
+             topBorder = Border.NONE;
+         }
+         if (selectedColumn == 0) {
+             leftBorder = Border.NONE;
+         }
+         if (selectedColumn > 0) {
+             columns.get(selectedColumn - 1).rightBorder = Border.NONE;
+         }
+         columns.get(selectedColumn).rightBorder = Border.NONE;
+         if (selectedRow > 0) {
+             rows.get(selectedRow - 1).bottomBorder = Border.NONE;
+             rows.get(selectedRow - 1).height = 1;
+         }
+         rows.get(selectedRow).bottomBorder = Border.NONE;
+         rows.get(selectedRow).height = 1;
+         bottomRightCorner();
+     }
+     /**
+      * Set all borders around the selected cell to Border.SINGLE.
+      */
+     public void setBorderCellSingle() {
+         if (selectedRow == 0) {
+             topBorder = Border.SINGLE;
+         }
+         if (selectedColumn == 0) {
+             leftBorder = Border.SINGLE;
+         }
+         if (selectedColumn > 0) {
+             columns.get(selectedColumn - 1).rightBorder = Border.SINGLE;
+         }
+         columns.get(selectedColumn).rightBorder = Border.SINGLE;
+         if (selectedRow > 0) {
+             rows.get(selectedRow - 1).bottomBorder = Border.SINGLE;
+             rows.get(selectedRow - 1).height = 2;
+         }
+         rows.get(selectedRow).bottomBorder = Border.SINGLE;
+         rows.get(selectedRow).height = 2;
+         alignGrid();
+     }
+     /**
+      * Set the column border to the right of the selected cell to
+      * Border.SINGLE.
+      */
+     public void setBorderColumnRightSingle() {
+         columns.get(selectedColumn).rightBorder = Border.SINGLE;
+         alignGrid();
+     }
+     /**
+      * Set the column border to the right of the selected cell to
+      * Border.SINGLE.
+      */
+     public void setBorderColumnLeftSingle() {
+         if (selectedColumn == 0) {
+             leftBorder = Border.SINGLE;
+         } else {
+             columns.get(selectedColumn - 1).rightBorder = Border.SINGLE;
+         }
+         alignGrid();
+     }
+     /**
+      * Set the row border above the selected cell to Border.SINGLE.
+      */
+     public void setBorderRowAboveSingle() {
+         if (selectedRow == 0) {
+             topBorder = Border.SINGLE;
+         } else {
+             rows.get(selectedRow - 1).bottomBorder = Border.SINGLE;
+             rows.get(selectedRow - 1).height = 2;
+         }
+         alignGrid();
+     }
+     /**
+      * Set the row border below the selected cell to Border.SINGLE.
+      */
+     public void setBorderRowBelowSingle() {
+         rows.get(selectedRow).bottomBorder = Border.SINGLE;
+         rows.get(selectedRow).height = 2;
+         alignGrid();
+     }
+     /**
+      * Set the row border below the selected cell to Border.DOUBLE.
+      */
+     public void setBorderRowBelowDouble() {
+         rows.get(selectedRow).bottomBorder = Border.DOUBLE;
+         rows.get(selectedRow).height = 2;
+         alignGrid();
+     }
+     /**
+      * Set the row border below the selected cell to Border.THICK.
+      */
+     public void setBorderRowBelowThick() {
+         rows.get(selectedRow).bottomBorder = Border.THICK;
+         rows.get(selectedRow).height = 2;
+         alignGrid();
+     }
+ }
index 0000000000000000000000000000000000000000,44ff7b48949749c1590bcead79b57f3b55d727fc..44ff7b48949749c1590bcead79b57f3b55d727fc
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,572 +1,572 @@@
+ /*
+  * Jexer - Java Text User Interface
+  *
+  * The MIT License (MIT)
+  *
+  * 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"),
+  * to deal in the Software without restriction, including without limitation
+  * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+  * and/or sell copies of the Software, and to permit persons to whom the
+  * Software is furnished to do so, subject to the following conditions:
+  *
+  * The above copyright notice and this permission notice shall be included in
+  * all copies or substantial portions of the Software.
+  *
+  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+  * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+  * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+  * DEALINGS IN THE SOFTWARE.
+  *
+  * @author Kevin Lamonte [kevin.lamonte@gmail.com]
+  * @version 1
+  */
+ package jexer;
+ import java.io.File;
+ import java.io.IOException;
+ import java.text.MessageFormat;
+ import java.util.ResourceBundle;
+ import jexer.event.TCommandEvent;
+ import jexer.event.TKeypressEvent;
+ import jexer.event.TMenuEvent;
+ import jexer.event.TMouseEvent;
+ import jexer.event.TResizeEvent;
+ import jexer.menu.TMenu;
+ import jexer.menu.TMenuItem;
+ import static jexer.TCommand.*;
+ import static jexer.TKeypress.*;
+ /**
+  * TTableWindow is used to display and edit regular two-dimensional tables of
+  * cells.
+  */
+ public class TTableWindow extends TScrollableWindow {
+     /**
+      * Translated strings.
+      */
+     private static final ResourceBundle i18n = ResourceBundle.getBundle(TTableWindow.class.getName());
+     // ------------------------------------------------------------------------
+     // Variables --------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * The table widget.
+      */
+     private TTableWidget tableField;
+     // ------------------------------------------------------------------------
+     // Constructors -----------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Public constructor sets window title.
+      *
+      * @param parent the main application
+      * @param title the window title
+      */
+     public TTableWindow(final TApplication parent, final String title) {
+         super(parent, title, 0, 0, parent.getScreen().getWidth() / 2,
+             parent.getScreen().getHeight() / 2 - 2, RESIZABLE | CENTERED);
+         tableField = addTable(0, 0, getWidth() - 2, getHeight() - 2);
+         setupAfterTable();
+     }
+     /**
+      * Public constructor loads a grid from a RFC4180 CSV file.
+      *
+      * @param parent the main application
+      * @param csvFile a File referencing the CSV data
+      * @throws IOException if a java.io operation throws
+      */
+     public TTableWindow(final TApplication parent,
+         final File csvFile) throws IOException {
+         super(parent, csvFile.getName(), 0, 0,
+             parent.getScreen().getWidth() / 2,
+             parent.getScreen().getHeight() / 2 - 2,
+             RESIZABLE | CENTERED);
+         tableField = addTable(0, 0, getWidth() - 2, getHeight() - 2, 1, 1);
+         setupAfterTable();
+         tableField.loadCsvFile(csvFile);
+     }
+     // ------------------------------------------------------------------------
+     // Event handlers ---------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Called by application.switchWindow() when this window gets the
+      * focus, and also by application.addWindow().
+      */
+     public void onFocus() {
+         // Enable the table menu items.
+         getApplication().enableMenuItem(TMenu.MID_CUT);
+         getApplication().enableMenuItem(TMenu.MID_TABLE_RENAME_COLUMN);
+         getApplication().enableMenuItem(TMenu.MID_TABLE_RENAME_ROW);
+         getApplication().enableMenuItem(TMenu.MID_TABLE_VIEW_ROW_LABELS);
+         getApplication().enableMenuItem(TMenu.MID_TABLE_VIEW_COLUMN_LABELS);
+         getApplication().enableMenuItem(TMenu.MID_TABLE_VIEW_HIGHLIGHT_ROW);
+         getApplication().enableMenuItem(TMenu.MID_TABLE_VIEW_HIGHLIGHT_COLUMN);
+         getApplication().enableMenuItem(TMenu.MID_TABLE_BORDER_NONE);
+         getApplication().enableMenuItem(TMenu.MID_TABLE_BORDER_ALL);
+         getApplication().enableMenuItem(TMenu.MID_TABLE_BORDER_CELL_NONE);
+         getApplication().enableMenuItem(TMenu.MID_TABLE_BORDER_CELL_ALL);
+         getApplication().enableMenuItem(TMenu.MID_TABLE_BORDER_RIGHT);
+         getApplication().enableMenuItem(TMenu.MID_TABLE_BORDER_LEFT);
+         getApplication().enableMenuItem(TMenu.MID_TABLE_BORDER_TOP);
+         getApplication().enableMenuItem(TMenu.MID_TABLE_BORDER_BOTTOM);
+         getApplication().enableMenuItem(TMenu.MID_TABLE_BORDER_DOUBLE_BOTTOM);
+         getApplication().enableMenuItem(TMenu.MID_TABLE_BORDER_THICK_BOTTOM);
+         getApplication().enableMenuItem(TMenu.MID_TABLE_DELETE_LEFT);
+         getApplication().enableMenuItem(TMenu.MID_TABLE_DELETE_UP);
+         getApplication().enableMenuItem(TMenu.MID_TABLE_DELETE_ROW);
+         getApplication().enableMenuItem(TMenu.MID_TABLE_DELETE_COLUMN);
+         getApplication().enableMenuItem(TMenu.MID_TABLE_INSERT_LEFT);
+         getApplication().enableMenuItem(TMenu.MID_TABLE_INSERT_RIGHT);
+         getApplication().enableMenuItem(TMenu.MID_TABLE_INSERT_ABOVE);
+         getApplication().enableMenuItem(TMenu.MID_TABLE_INSERT_BELOW);
+         getApplication().enableMenuItem(TMenu.MID_TABLE_COLUMN_NARROW);
+         getApplication().enableMenuItem(TMenu.MID_TABLE_COLUMN_WIDEN);
+         getApplication().enableMenuItem(TMenu.MID_TABLE_FILE_OPEN_CSV);
+         getApplication().enableMenuItem(TMenu.MID_TABLE_FILE_SAVE_CSV);
+         getApplication().enableMenuItem(TMenu.MID_TABLE_FILE_SAVE_TEXT);
+         if (tableField != null) {
+             // Set the menu to match the flags.
+             TMenuItem menuItem = getApplication().getMenuItem(TMenu.MID_TABLE_VIEW_ROW_LABELS);
+             if (menuItem != null) {
+                 menuItem.setChecked(tableField.getShowRowLabels());
+             }
+             menuItem = getApplication().getMenuItem(TMenu.MID_TABLE_VIEW_COLUMN_LABELS);
+             if (menuItem != null) {
+                 menuItem.setChecked(tableField.getShowColumnLabels());
+             }
+             menuItem = getApplication().getMenuItem(TMenu.MID_TABLE_VIEW_HIGHLIGHT_ROW);
+             if (menuItem != null) {
+                 menuItem.setChecked(tableField.getHighlightRow());
+             }
+             menuItem = getApplication().getMenuItem(TMenu.MID_TABLE_VIEW_HIGHLIGHT_COLUMN);
+             if (menuItem != null) {
+                 menuItem.setChecked(tableField.getHighlightColumn());
+             }
+         }
+     }
+     /**
+      * Called by application.switchWindow() when another window gets the
+      * focus.
+      */
+     public void onUnfocus() {
+         // Disable the table menu items.
+         getApplication().disableMenuItem(TMenu.MID_CUT);
+         getApplication().disableMenuItem(TMenu.MID_TABLE_RENAME_COLUMN);
+         getApplication().disableMenuItem(TMenu.MID_TABLE_RENAME_ROW);
+         getApplication().disableMenuItem(TMenu.MID_TABLE_VIEW_ROW_LABELS);
+         getApplication().disableMenuItem(TMenu.MID_TABLE_VIEW_COLUMN_LABELS);
+         getApplication().disableMenuItem(TMenu.MID_TABLE_VIEW_HIGHLIGHT_ROW);
+         getApplication().disableMenuItem(TMenu.MID_TABLE_VIEW_HIGHLIGHT_COLUMN);
+         getApplication().disableMenuItem(TMenu.MID_TABLE_BORDER_NONE);
+         getApplication().disableMenuItem(TMenu.MID_TABLE_BORDER_ALL);
+         getApplication().disableMenuItem(TMenu.MID_TABLE_BORDER_CELL_NONE);
+         getApplication().disableMenuItem(TMenu.MID_TABLE_BORDER_CELL_ALL);
+         getApplication().disableMenuItem(TMenu.MID_TABLE_BORDER_RIGHT);
+         getApplication().disableMenuItem(TMenu.MID_TABLE_BORDER_LEFT);
+         getApplication().disableMenuItem(TMenu.MID_TABLE_BORDER_TOP);
+         getApplication().disableMenuItem(TMenu.MID_TABLE_BORDER_BOTTOM);
+         getApplication().disableMenuItem(TMenu.MID_TABLE_BORDER_DOUBLE_BOTTOM);
+         getApplication().disableMenuItem(TMenu.MID_TABLE_BORDER_THICK_BOTTOM);
+         getApplication().disableMenuItem(TMenu.MID_TABLE_DELETE_LEFT);
+         getApplication().disableMenuItem(TMenu.MID_TABLE_DELETE_UP);
+         getApplication().disableMenuItem(TMenu.MID_TABLE_DELETE_ROW);
+         getApplication().disableMenuItem(TMenu.MID_TABLE_DELETE_COLUMN);
+         getApplication().disableMenuItem(TMenu.MID_TABLE_INSERT_LEFT);
+         getApplication().disableMenuItem(TMenu.MID_TABLE_INSERT_RIGHT);
+         getApplication().disableMenuItem(TMenu.MID_TABLE_INSERT_ABOVE);
+         getApplication().disableMenuItem(TMenu.MID_TABLE_INSERT_BELOW);
+         getApplication().disableMenuItem(TMenu.MID_TABLE_COLUMN_NARROW);
+         getApplication().disableMenuItem(TMenu.MID_TABLE_COLUMN_WIDEN);
+         getApplication().disableMenuItem(TMenu.MID_TABLE_FILE_OPEN_CSV);
+         getApplication().disableMenuItem(TMenu.MID_TABLE_FILE_SAVE_CSV);
+         getApplication().disableMenuItem(TMenu.MID_TABLE_FILE_SAVE_TEXT);
+     }
+     // ------------------------------------------------------------------------
+     // TWindow ----------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Handle mouse press events.
+      *
+      * @param mouse mouse button press event
+      */
+     @Override
+     public void onMouseDown(final TMouseEvent mouse) {
+         // Use TWidget's code to pass the event to the children.
+         super.onMouseDown(mouse);
+         if (mouseOnTable(mouse)) {
+             // The table might have changed, update the scollbars.
+             setBottomValue(tableField.getRowCount() - 1);
+             setVerticalValue(tableField.getSelectedRowNumber());
+             setRightValue(tableField.getColumnCount() - 1);
+             setHorizontalValue(tableField.getSelectedColumnNumber());
+         }
+     }
+     /**
+      * Handle mouse release events.
+      *
+      * @param mouse mouse button release event
+      */
+     @Override
+     public void onMouseUp(final TMouseEvent mouse) {
+         // Use TWidget's code to pass the event to the children.
+         super.onMouseUp(mouse);
+         if (mouse.isMouse1() && mouseOnVerticalScroller(mouse)) {
+             // Clicked/dragged on vertical scrollbar.
+             tableField.setSelectedRowNumber(getVerticalValue());
+         }
+         if (mouse.isMouse1() && mouseOnHorizontalScroller(mouse)) {
+             // Clicked/dragged on horizontal scrollbar.
+             tableField.setSelectedColumnNumber(getHorizontalValue());
+         }
+     }
+     /**
+      * Method that subclasses can override to handle mouse movements.
+      *
+      * @param mouse mouse motion event
+      */
+     @Override
+     public void onMouseMotion(final TMouseEvent mouse) {
+         // Use TWidget's code to pass the event to the children.
+         super.onMouseMotion(mouse);
+         if (mouseOnTable(mouse) && mouse.isMouse1()) {
+             // The table might have changed, update the scollbars.
+             setBottomValue(tableField.getRowCount() - 1);
+             setVerticalValue(tableField.getSelectedRowNumber());
+             setRightValue(tableField.getColumnCount() - 1);
+             setHorizontalValue(tableField.getSelectedColumnNumber());
+         } else {
+             if (mouse.isMouse1() && mouseOnVerticalScroller(mouse)) {
+                 // Clicked/dragged on vertical scrollbar.
+                 tableField.setSelectedRowNumber(getVerticalValue());
+             }
+             if (mouse.isMouse1() && mouseOnHorizontalScroller(mouse)) {
+                 // Clicked/dragged on horizontal scrollbar.
+                 tableField.setSelectedColumnNumber(getHorizontalValue());
+             }
+         }
+     }
+     /**
+      * Handle keystrokes.
+      *
+      * @param keypress keystroke event
+      */
+     @Override
+     public void onKeypress(final TKeypressEvent keypress) {
+         // Use TWidget's code to pass the event to the children.
+         super.onKeypress(keypress);
+         // The table might have changed, update the scollbars.
+         setBottomValue(tableField.getRowCount() - 1);
+         setVerticalValue(tableField.getSelectedRowNumber());
+         setRightValue(tableField.getColumnCount() - 1);
+         setHorizontalValue(tableField.getSelectedColumnNumber());
+     }
+     /**
+      * Handle window/screen resize events.
+      *
+      * @param event resize event
+      */
+     @Override
+     public void onResize(final TResizeEvent event) {
+         if (event.getType() == TResizeEvent.Type.WIDGET) {
+             // Resize the table
+             TResizeEvent tableSize = new TResizeEvent(TResizeEvent.Type.WIDGET,
+                 event.getWidth() - 2, event.getHeight() - 2);
+             tableField.onResize(tableSize);
+             // Have TScrollableWindow handle the scrollbars
+             super.onResize(event);
+             return;
+         }
+         // Pass to children instead
+         for (TWidget widget: getChildren()) {
+             widget.onResize(event);
+         }
+     }
+     /**
+      * Method that subclasses can override to handle posted command events.
+      *
+      * @param command command event
+      */
+     @Override
+     public void onCommand(final TCommandEvent command) {
+         if (command.equals(cmOpen)) {
+             try {
+                 String filename = fileOpenBox(".");
+                 if (filename != null) {
+                     try {
+                         new TTableWindow(getApplication(), new File(filename));
+                     } catch (IOException e) {
+                         messageBox(i18n.getString("errorDialogTitle"),
+                             MessageFormat.format(i18n.
+                                 getString("errorReadingFile"), e.getMessage()));
+                     }
+                 }
+             } catch (IOException e) {
+                 messageBox(i18n.getString("errorDialogTitle"),
+                     MessageFormat.format(i18n.
+                         getString("errorOpeningFileDialog"), e.getMessage()));
+             }
+             return;
+         }
+         if (command.equals(cmSave)) {
+             try {
+                 String filename = fileSaveBox(".");
+                 if (filename != null) {
+                     tableField.saveToCsvFilename(filename);
+                 }
+             } catch (IOException e) {
+                 messageBox(i18n.getString("errorDialogTitle"),
+                     MessageFormat.format(i18n.
+                         getString("errorWritingFile"), e.getMessage()));
+             }
+             return;
+         }
+         // Didn't handle it, let children get it instead
+         super.onCommand(command);
+     }
+     /**
+      * Handle posted menu events.
+      *
+      * @param menu menu event
+      */
+     @Override
+     public void onMenu(final TMenuEvent menu) {
+         TInputBox inputBox = null;
+         String filename = null;
+         switch (menu.getId()) {
+         case TMenu.MID_TABLE_RENAME_COLUMN:
+             inputBox = inputBox(i18n.getString("renameColumnInputTitle"),
+                 i18n.getString("renameColumnInputCaption"),
+                 tableField.getColumnLabel(tableField.getSelectedColumnNumber()),
+                 TMessageBox.Type.OKCANCEL);
+             if (inputBox.isOk()) {
+                 tableField.setColumnLabel(tableField.getSelectedColumnNumber(),
+                     inputBox.getText());
+             }
+             return;
+         case TMenu.MID_TABLE_RENAME_ROW:
+             inputBox = inputBox(i18n.getString("renameRowInputTitle"),
+                 i18n.getString("renameRowInputCaption"),
+                 tableField.getRowLabel(tableField.getSelectedRowNumber()),
+                 TMessageBox.Type.OKCANCEL);
+             if (inputBox.isOk()) {
+                 tableField.setRowLabel(tableField.getSelectedRowNumber(),
+                     inputBox.getText());
+             }
+             return;
+         case TMenu.MID_TABLE_VIEW_ROW_LABELS:
+             tableField.setShowRowLabels(getApplication().getMenuItem(
+                 menu.getId()).getChecked());
+             return;
+         case TMenu.MID_TABLE_VIEW_COLUMN_LABELS:
+             tableField.setShowColumnLabels(getApplication().getMenuItem(
+                 menu.getId()).getChecked());
+             return;
+         case TMenu.MID_TABLE_VIEW_HIGHLIGHT_ROW:
+             tableField.setHighlightRow(getApplication().getMenuItem(
+                 menu.getId()).getChecked());
+             return;
+         case TMenu.MID_TABLE_VIEW_HIGHLIGHT_COLUMN:
+             tableField.setHighlightColumn(getApplication().getMenuItem(
+                 menu.getId()).getChecked());
+             return;
+         case TMenu.MID_TABLE_BORDER_NONE:
+             tableField.setBorderAllNone();
+             return;
+         case TMenu.MID_TABLE_BORDER_ALL:
+             tableField.setBorderAllSingle();
+             return;
+         case TMenu.MID_TABLE_BORDER_CELL_NONE:
+             tableField.setBorderCellNone();
+             return;
+         case TMenu.MID_TABLE_BORDER_CELL_ALL:
+             tableField.setBorderCellSingle();
+             return;
+         case TMenu.MID_TABLE_BORDER_RIGHT:
+             tableField.setBorderColumnRightSingle();
+             return;
+         case TMenu.MID_TABLE_BORDER_LEFT:
+             tableField.setBorderColumnLeftSingle();
+             return;
+         case TMenu.MID_TABLE_BORDER_TOP:
+             tableField.setBorderRowAboveSingle();
+             return;
+         case TMenu.MID_TABLE_BORDER_BOTTOM:
+             tableField.setBorderRowBelowSingle();
+             return;
+         case TMenu.MID_TABLE_BORDER_DOUBLE_BOTTOM:
+             tableField.setBorderRowBelowDouble();
+             return;
+         case TMenu.MID_TABLE_BORDER_THICK_BOTTOM:
+             tableField.setBorderRowBelowThick();
+             return;
+         case TMenu.MID_TABLE_DELETE_LEFT:
+             tableField.deleteCellShiftLeft();
+             return;
+         case TMenu.MID_TABLE_DELETE_UP:
+             tableField.deleteCellShiftUp();
+             return;
+         case TMenu.MID_TABLE_DELETE_ROW:
+             tableField.deleteRow(tableField.getSelectedRowNumber());
+             return;
+         case TMenu.MID_TABLE_DELETE_COLUMN:
+             tableField.deleteColumn(tableField.getSelectedColumnNumber());
+             return;
+         case TMenu.MID_TABLE_INSERT_LEFT:
+             tableField.insertColumnLeft(tableField.getSelectedColumnNumber());
+             return;
+         case TMenu.MID_TABLE_INSERT_RIGHT:
+             tableField.insertColumnRight(tableField.getSelectedColumnNumber());
+             return;
+         case TMenu.MID_TABLE_INSERT_ABOVE:
+             tableField.insertRowAbove(tableField.getSelectedColumnNumber());
+             return;
+         case TMenu.MID_TABLE_INSERT_BELOW:
+             tableField.insertRowBelow(tableField.getSelectedColumnNumber());
+             return;
+         case TMenu.MID_TABLE_COLUMN_NARROW:
+             tableField.setColumnWidth(tableField.getSelectedColumnNumber(),
+                 tableField.getColumnWidth(tableField.getSelectedColumnNumber()) - 1);
+             return;
+         case TMenu.MID_TABLE_COLUMN_WIDEN:
+             tableField.setColumnWidth(tableField.getSelectedColumnNumber(),
+                 tableField.getColumnWidth(tableField.getSelectedColumnNumber()) + 1);
+             return;
+         case TMenu.MID_TABLE_FILE_OPEN_CSV:
+             try {
+                 filename = fileOpenBox(".");
+                 if (filename != null) {
+                     try {
+                         new TTableWindow(getApplication(), new File(filename));
+                     } catch (IOException e) {
+                         messageBox(i18n.getString("errorDialogTitle"),
+                             MessageFormat.format(i18n.
+                                 getString("errorReadingFile"), e.getMessage()));
+                     }
+                 }
+             } catch (IOException e) {
+                 messageBox(i18n.getString("errorDialogTitle"),
+                     MessageFormat.format(i18n.
+                         getString("errorOpeningFileDialog"), e.getMessage()));
+             }
+             return;
+         case TMenu.MID_TABLE_FILE_SAVE_CSV:
+             try {
+                 filename = fileSaveBox(".");
+                 if (filename != null) {
+                     tableField.saveToCsvFilename(filename);
+                 }
+             } catch (IOException e) {
+                 messageBox(i18n.getString("errorDialogTitle"),
+                     MessageFormat.format(i18n.
+                         getString("errorWritingFile"), e.getMessage()));
+             }
+             return;
+         case TMenu.MID_TABLE_FILE_SAVE_TEXT:
+             try {
+                 filename = fileSaveBox(".");
+                 if (filename != null) {
+                     tableField.saveToTextFilename(filename);
+                 }
+             } catch (IOException e) {
+                 messageBox(i18n.getString("errorDialogTitle"),
+                     MessageFormat.format(i18n.
+                         getString("errorWritingFile"), e.getMessage()));
+             }
+             return;
+         default:
+             break;
+         }
+         super.onMenu(menu);
+     }
+     // ------------------------------------------------------------------------
+     // TTableWindow -----------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Setup other fields after the table is created.
+      */
+     private void setupAfterTable() {
+         hScroller = new THScroller(this, 17, getHeight() - 2, getWidth() - 20);
+         vScroller = new TVScroller(this, getWidth() - 2, 0, getHeight() - 2);
+         setMinimumWindowWidth(25);
+         setMinimumWindowHeight(10);
+         setTopValue(tableField.getSelectedRowNumber());
+         setBottomValue(tableField.getRowCount() - 1);
+         setLeftValue(tableField.getSelectedColumnNumber());
+         setRightValue(tableField.getColumnCount() - 1);
+         statusBar = newStatusBar(i18n.getString("statusBar"));
+         statusBar.addShortcutKeypress(kbF1, cmHelp,
+             i18n.getString("statusBarHelp"));
+         statusBar.addShortcutKeypress(kbF2, cmSave,
+             i18n.getString("statusBarSave"));
+         statusBar.addShortcutKeypress(kbF3, cmOpen,
+             i18n.getString("statusBarOpen"));
+         statusBar.addShortcutKeypress(kbF10, cmMenu,
+             i18n.getString("statusBarMenu"));
+         // Synchronize the menu with tableField's flags.
+         onFocus();
+     }
+     /**
+      * Check if a mouse press/release/motion event coordinate is over the
+      * table.
+      *
+      * @param mouse a mouse-based event
+      * @return whether or not the mouse is on the table
+      */
+     private boolean mouseOnTable(final TMouseEvent mouse) {
+         if ((mouse.getAbsoluteX() >= getAbsoluteX() + 1)
+             && (mouse.getAbsoluteX() <  getAbsoluteX() + getWidth() - 1)
+             && (mouse.getAbsoluteY() >= getAbsoluteY() + 1)
+             && (mouse.getAbsoluteY() <  getAbsoluteY() + getHeight() - 1)
+         ) {
+             return true;
+         }
+         return false;
+     }
+ }
index 0000000000000000000000000000000000000000,c2c87651968903386463949b304949111f038a5c..c2c87651968903386463949b304949111f038a5c
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,15 +1,15 @@@
+ statusBar=Editor
+ statusBarHelp=Help
+ statusBarSave=Save CSV
+ statusBarOpen=Open CSV
+ statusBarMenu=Menu
+ renameRowInputTitle=Rename Row
+ renameRowInputCaption=New row name?
+ renameColumnInputTitle=Rename Column
+ renameColumnInputCaption=New column name?
+ errorDialogTitle=Error
+ errorReadingFile=Error reading file: {0}
+ errorOpeningFileDialog=Error opening file dialog: {0}
+ errorSavingFile=Error saving file: {0}
index 0000000000000000000000000000000000000000,a2696092ce82ee7ed0550a0019b6b8fc0c9c785d..a2696092ce82ee7ed0550a0019b6b8fc0c9c785d
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,1156 +1,1156 @@@
+ /*
+  * Jexer - Java Text User Interface
+  *
+  * The MIT License (MIT)
+  *
+  * 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"),
+  * to deal in the Software without restriction, including without limitation
+  * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+  * and/or sell copies of the Software, and to permit persons to whom the
+  * Software is furnished to do so, subject to the following conditions:
+  *
+  * The above copyright notice and this permission notice shall be included in
+  * all copies or substantial portions of the Software.
+  *
+  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+  * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+  * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+  * DEALINGS IN THE SOFTWARE.
+  *
+  * @author Kevin Lamonte [kevin.lamonte@gmail.com]
+  * @version 1
+  */
+ package jexer;
+ import java.awt.Font;
+ import java.awt.FontMetrics;
+ import java.awt.Graphics2D;
+ import java.awt.image.BufferedImage;
+ import java.io.InputStream;
+ import java.io.IOException;
+ import java.lang.reflect.Field;
+ import java.text.MessageFormat;
+ import java.util.ArrayList;
+ import java.util.HashMap;
+ import java.util.List;
+ import java.util.Map;
+ import java.util.ResourceBundle;
+ import jexer.backend.ECMA48Terminal;
+ import jexer.backend.GlyphMaker;
+ import jexer.backend.MultiScreen;
+ import jexer.backend.SwingTerminal;
+ import jexer.bits.Cell;
+ import jexer.bits.CellAttributes;
+ import jexer.event.TKeypressEvent;
+ import jexer.event.TMenuEvent;
+ import jexer.event.TMouseEvent;
+ import jexer.event.TResizeEvent;
+ import jexer.menu.TMenu;
+ import jexer.tterminal.DisplayLine;
+ import jexer.tterminal.DisplayListener;
+ import jexer.tterminal.ECMA48;
+ import static jexer.TKeypress.*;
+ /**
+  * TTerminalWidget exposes a ECMA-48 / ANSI X3.64 style terminal in a widget.
+  */
+ public class TTerminalWidget extends TScrollableWidget
+                              implements DisplayListener {
+     /**
+      * Translated strings.
+      */
+     private static final ResourceBundle i18n = ResourceBundle.getBundle(TTerminalWidget.class.getName());
+     // ------------------------------------------------------------------------
+     // Variables --------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * The emulator.
+      */
+     private ECMA48 emulator;
+     /**
+      * The Process created by the shell spawning constructor.
+      */
+     private Process shell;
+     /**
+      * If true, we are using the ptypipe utility to support dynamic window
+      * resizing.  ptypipe is available at
+      * https://gitlab.com/klamonte/ptypipe .
+      */
+     private boolean ptypipe = false;
+     /**
+      * Double-height font.
+      */
+     private GlyphMaker doubleFont;
+     /**
+      * Last text width value.
+      */
+     private int lastTextWidth = -1;
+     /**
+      * Last text height value.
+      */
+     private int lastTextHeight = -1;
+     /**
+      * The blink state, used only by ECMA48 backend and when double-width
+      * chars must be drawn.
+      */
+     private boolean blinkState = true;
+     /**
+      * Timer flag, used only by ECMA48 backend and when double-width chars
+      * must be drawn.
+      */
+     private boolean haveTimer = false;
+     /**
+      * The last seen visible display.
+      */
+     private List<DisplayLine> display;
+     /**
+      * If true, the display has changed and needs updating.
+      */
+     private volatile boolean dirty = true;
+     /**
+      * Time that the display was last updated.
+      */
+     private long lastUpdateTime = 0;
+     /**
+      * If true, hide the mouse after typing a keystroke.
+      */
+     private boolean hideMouseWhenTyping = true;
+     /**
+      * If true, the mouse should not be displayed because a keystroke was
+      * typed.
+      */
+     private boolean typingHidMouse = false;
+     /**
+      * The return value from the emulator.
+      */
+     private int exitValue = -1;
+     /**
+      * Title to expose to a window.
+      */
+     private String title = "";
+     /**
+      * Action to perform when the terminal exits.
+      */
+     private TAction closeAction = null;
+     // ------------------------------------------------------------------------
+     // Constructors -----------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Public constructor spawns a custom command line.
+      *
+      * @param parent parent widget
+      * @param x column relative to parent
+      * @param y row relative to parent
+      * @param commandLine the command line to execute
+      */
+     public TTerminalWidget(final TWidget parent, final int x, final int y,
+         final String commandLine) {
+         this(parent, x, y, commandLine.split("\\s+"));
+     }
+     /**
+      * Public constructor spawns a custom command line.
+      *
+      * @param parent parent widget
+      * @param x column relative to parent
+      * @param y row relative to parent
+      * @param command the command line to execute
+      */
+     public TTerminalWidget(final TWidget parent, final int x, final int y,
+         final String [] command) {
+         this(parent, x, y, command, null);
+     }
+     /**
+      * Public constructor spawns a custom command line.
+      *
+      * @param parent parent widget
+      * @param x column relative to parent
+      * @param y row relative to parent
+      * @param command the command line to execute
+      * @param closeAction action to perform when the shell sxits
+      */
+     public TTerminalWidget(final TWidget parent, final int x, final int y,
+         final String [] command, final TAction closeAction) {
+         this(parent, x, y, 80, 24, command, closeAction);
+     }
+     /**
+      * Public constructor spawns a custom command line.
+      *
+      * @param parent parent widget
+      * @param x column relative to parent
+      * @param y row relative to parent
+      * @param width width of widget
+      * @param height height of widget
+      * @param command the command line to execute
+      * @param closeAction action to perform when the shell sxits
+      */
+     public TTerminalWidget(final TWidget parent, final int x, final int y,
+         final int width, final int height, final String [] command,
+         final TAction closeAction) {
+         super(parent, x, y, width, height);
+         this.closeAction = closeAction;
+         String [] fullCommand;
+         // Spawn a shell and pass its I/O to the other constructor.
+         if ((System.getProperty("jexer.TTerminal.ptypipe") != null)
+             && (System.getProperty("jexer.TTerminal.ptypipe").
+                 equals("true"))
+         ) {
+             ptypipe = true;
+             fullCommand = new String[command.length + 1];
+             fullCommand[0] = "ptypipe";
+             System.arraycopy(command, 0, fullCommand, 1, command.length);
+         } else if (System.getProperty("os.name").startsWith("Windows")) {
+             fullCommand = new String[3];
+             fullCommand[0] = "cmd";
+             fullCommand[1] = "/c";
+             fullCommand[2] = stringArrayToString(command);
+         } else if (System.getProperty("os.name").startsWith("Mac")) {
+             fullCommand = new String[6];
+             fullCommand[0] = "script";
+             fullCommand[1] = "-q";
+             fullCommand[2] = "-F";
+             fullCommand[3] = "/dev/null";
+             fullCommand[4] = "-c";
+             fullCommand[5] = stringArrayToString(command);
+         } else {
+             // Default: behave like Linux
+             fullCommand = new String[5];
+             fullCommand[0] = "script";
+             fullCommand[1] = "-fqe";
+             fullCommand[2] = "/dev/null";
+             fullCommand[3] = "-c";
+             fullCommand[4] = stringArrayToString(command);
+         }
+         spawnShell(fullCommand);
+     }
+     /**
+      * Public constructor spawns a shell.
+      *
+      * @param parent parent widget
+      * @param x column relative to parent
+      * @param y row relative to parent
+      */
+     public TTerminalWidget(final TWidget parent, final int x, final int y) {
+         this(parent, x, y, (TAction) null);
+     }
+     /**
+      * Public constructor spawns a shell.
+      *
+      * @param parent parent widget
+      * @param x column relative to parent
+      * @param y row relative to parent
+      * @param closeAction action to perform when the shell sxits
+      */
+     public TTerminalWidget(final TWidget parent, final int x, final int y,
+         final TAction closeAction) {
+         this(parent, x, y, 80, 24, closeAction);
+     }
+     /**
+      * Public constructor spawns a shell.
+      *
+      * @param parent parent widget
+      * @param x column relative to parent
+      * @param y row relative to parent
+      * @param width width of widget
+      * @param height height of widget
+      * @param closeAction action to perform when the shell sxits
+      */
+     public TTerminalWidget(final TWidget parent, final int x, final int y,
+         final int width, final int height, final TAction closeAction) {
+         super(parent, x, y, width, height);
+         this.closeAction = closeAction;
+         if (System.getProperty("jexer.TTerminal.shell") != null) {
+             String shell = System.getProperty("jexer.TTerminal.shell");
+             if (shell.trim().startsWith("ptypipe")) {
+                 ptypipe = true;
+             }
+             spawnShell(shell.split("\\s+"));
+             return;
+         }
+         String cmdShellWindows = "cmd.exe";
+         // You cannot run a login shell in a bare Process interactively, due
+         // to libc's behavior of buffering when stdin/stdout aren't a tty.
+         // Use 'script' instead to run a shell in a pty.  And because BSD and
+         // GNU differ on the '-f' vs '-F' flags, we need two different
+         // commands.  Lovely.
+         String cmdShellGNU = "script -fqe /dev/null";
+         String cmdShellBSD = "script -q -F /dev/null";
+         // ptypipe is another solution that permits dynamic window resizing.
+         String cmdShellPtypipe = "ptypipe /bin/bash --login";
+         // Spawn a shell and pass its I/O to the other constructor.
+         if ((System.getProperty("jexer.TTerminal.ptypipe") != null)
+             && (System.getProperty("jexer.TTerminal.ptypipe").
+                 equals("true"))
+         ) {
+             ptypipe = true;
+             spawnShell(cmdShellPtypipe.split("\\s+"));
+         } else if (System.getProperty("os.name").startsWith("Windows")) {
+             spawnShell(cmdShellWindows.split("\\s+"));
+         } else if (System.getProperty("os.name").startsWith("Mac")) {
+             spawnShell(cmdShellBSD.split("\\s+"));
+         } else if (System.getProperty("os.name").startsWith("Linux")) {
+             spawnShell(cmdShellGNU.split("\\s+"));
+         } else {
+             // When all else fails, assume GNU.
+             spawnShell(cmdShellGNU.split("\\s+"));
+         }
+     }
+     // ------------------------------------------------------------------------
+     // Event handlers ---------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Handle window/screen resize events.
+      *
+      * @param resize resize event
+      */
+     @Override
+     public void onResize(final TResizeEvent resize) {
+         // Let TWidget set my size.
+         super.onResize(resize);
+         if (emulator == null) {
+             return;
+         }
+         // Synchronize against the emulator so we don't stomp on its reader
+         // thread.
+         synchronized (emulator) {
+             if (resize.getType() == TResizeEvent.Type.WIDGET) {
+                 // Resize the scroll bars
+                 reflowData();
+                 placeScrollbars();
+                 // Get out of scrollback
+                 setVerticalValue(0);
+                 if (ptypipe) {
+                     emulator.setWidth(getWidth());
+                     emulator.setHeight(getHeight());
+                     emulator.writeRemote("\033[8;" + getHeight() + ";" +
+                         getWidth() + "t");
+                 }
+                 // Pass the correct text cell width/height to the emulator
+                 if (getScreen() != null) {
+                     emulator.setTextWidth(getScreen().getTextWidth());
+                     emulator.setTextHeight(getScreen().getTextHeight());
+                 }
+             }
+             return;
+         } // synchronized (emulator)
+     }
+     /**
+      * Handle keystrokes.
+      *
+      * @param keypress keystroke event
+      */
+     @Override
+     public void onKeypress(final TKeypressEvent keypress) {
+         if (hideMouseWhenTyping) {
+             typingHidMouse = true;
+         }
+         // Scrollback up/down
+         if (keypress.equals(kbShiftPgUp)
+             || keypress.equals(kbCtrlPgUp)
+             || keypress.equals(kbAltPgUp)
+         ) {
+             bigVerticalDecrement();
+             dirty = true;
+             return;
+         }
+         if (keypress.equals(kbShiftPgDn)
+             || keypress.equals(kbCtrlPgDn)
+             || keypress.equals(kbAltPgDn)
+         ) {
+             bigVerticalIncrement();
+             dirty = true;
+             return;
+         }
+         if ((emulator != null) && (emulator.isReading())) {
+             // Get out of scrollback
+             setVerticalValue(0);
+             emulator.addUserEvent(keypress);
+             // UGLY HACK TIME!  cmd.exe needs CRLF, not just CR, so if
+             // this is kBEnter then also send kbCtrlJ.
+             if (keypress.equals(kbEnter)) {
+                 if (System.getProperty("os.name").startsWith("Windows")
+                     && (System.getProperty("jexer.TTerminal.cmdHack",
+                             "true").equals("true"))
+                 ) {
+                     emulator.addUserEvent(new TKeypressEvent(kbCtrlJ));
+                 }
+             }
+             readEmulatorState();
+             return;
+         }
+         // Process is closed, honor "normal" TUI keystrokes
+         super.onKeypress(keypress);
+     }
+     /**
+      * Handle mouse press events.
+      *
+      * @param mouse mouse button press event
+      */
+     @Override
+     public void onMouseDown(final TMouseEvent mouse) {
+         if (hideMouseWhenTyping) {
+             typingHidMouse = false;
+         }
+         if (emulator != null) {
+             // If the emulator is tracking mouse buttons, it needs to see
+             // wheel events.
+             if (emulator.getMouseProtocol() == ECMA48.MouseProtocol.OFF) {
+                 if (mouse.isMouseWheelUp()) {
+                     verticalDecrement();
+                     dirty = true;
+                     return;
+                 }
+                 if (mouse.isMouseWheelDown()) {
+                     verticalIncrement();
+                     dirty = true;
+                     return;
+                 }
+             }
+             if (mouseOnEmulator(mouse)) {
+                 emulator.addUserEvent(mouse);
+                 readEmulatorState();
+                 return;
+             }
+         }
+         // Emulator didn't consume it, pass it on
+         super.onMouseDown(mouse);
+     }
+     /**
+      * Handle mouse release events.
+      *
+      * @param mouse mouse button release event
+      */
+     @Override
+     public void onMouseUp(final TMouseEvent mouse) {
+         if (hideMouseWhenTyping) {
+             typingHidMouse = false;
+         }
+         if ((emulator != null) && (mouseOnEmulator(mouse))) {
+             emulator.addUserEvent(mouse);
+             readEmulatorState();
+             return;
+         }
+         // Emulator didn't consume it, pass it on
+         super.onMouseUp(mouse);
+     }
+     /**
+      * Handle mouse motion events.
+      *
+      * @param mouse mouse motion event
+      */
+     @Override
+     public void onMouseMotion(final TMouseEvent mouse) {
+         if (hideMouseWhenTyping) {
+             typingHidMouse = false;
+         }
+         if ((emulator != null) && (mouseOnEmulator(mouse))) {
+             emulator.addUserEvent(mouse);
+             readEmulatorState();
+             return;
+         }
+         // Emulator didn't consume it, pass it on
+         super.onMouseMotion(mouse);
+     }
+     // ------------------------------------------------------------------------
+     // TScrollableWidget ------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Draw the display buffer.
+      */
+     @Override
+     public void draw() {
+         if (emulator == null) {
+             return;
+         }
+         int width = getDisplayWidth();
+         boolean syncEmulator = false;
+         if ((System.currentTimeMillis() - lastUpdateTime >= 20)
+             && (dirty == true)
+         ) {
+             // Too much time has passed, draw it all.
+             syncEmulator = true;
+         } else if (emulator.isReading() && (dirty == false)) {
+             // Wait until the emulator has brought more data in.
+             syncEmulator = false;
+         } else if (!emulator.isReading() && (dirty == true)) {
+             // The emulator won't receive more data, update the display.
+             syncEmulator = true;
+         }
+         if ((syncEmulator == true)
+             || (display == null)
+         ) {
+             // We want to minimize the amount of time we have the emulator
+             // locked.  Grab a copy of its display.
+             synchronized (emulator) {
+                 // Update the scroll bars
+                 reflowData();
+                 if (!isDrawable()) {
+                     // We lost the connection, onShellExit() called an action
+                     // that ultimately removed this widget from the UI
+                     // hierarchy, so no one cares if we update the display.
+                     // Bail out.
+                     return;
+                 }
+                 if ((display == null) || emulator.isReading()) {
+                     display = emulator.getVisibleDisplay(getHeight(),
+                         -getVerticalValue());
+                     assert (display.size() == getHeight());
+                 }
+                 width = emulator.getWidth();
+             }
+             dirty = false;
+         }
+         // Now draw the emulator screen
+         int row = 0;
+         for (DisplayLine line: display) {
+             int widthMax = width;
+             if (line.isDoubleWidth()) {
+                 widthMax /= 2;
+             }
+             if (widthMax > getWidth()) {
+                 widthMax = getWidth();
+             }
+             for (int i = 0; i < widthMax; i++) {
+                 Cell ch = line.charAt(i);
+                 if (ch.isImage()) {
+                     putCharXY(i, row, ch);
+                     continue;
+                 }
+                 Cell newCell = new Cell(ch);
+                 boolean reverse = line.isReverseColor() ^ ch.isReverse();
+                 newCell.setReverse(false);
+                 if (reverse) {
+                     if (ch.getForeColorRGB() < 0) {
+                         newCell.setBackColor(ch.getForeColor());
+                         newCell.setBackColorRGB(-1);
+                     } else {
+                         newCell.setBackColorRGB(ch.getForeColorRGB());
+                     }
+                     if (ch.getBackColorRGB() < 0) {
+                         newCell.setForeColor(ch.getBackColor());
+                         newCell.setForeColorRGB(-1);
+                     } else {
+                         newCell.setForeColorRGB(ch.getBackColorRGB());
+                     }
+                 }
+                 if (line.isDoubleWidth()) {
+                     putDoubleWidthCharXY(line, (i * 2), row, newCell);
+                 } else {
+                     putCharXY(i, row, newCell);
+                 }
+             }
+             row++;
+         }
+     }
+     /**
+      * Set current value of the vertical scroll.
+      *
+      * @param value the new scroll value
+      */
+     @Override
+     public void setVerticalValue(final int value) {
+         super.setVerticalValue(value);
+         dirty = true;
+     }
+     /**
+      * Perform a small step change up.
+      */
+     @Override
+     public void verticalDecrement() {
+         super.verticalDecrement();
+         dirty = true;
+     }
+     /**
+      * Perform a small step change down.
+      */
+     @Override
+     public void verticalIncrement() {
+         super.verticalIncrement();
+         dirty = true;
+     }
+     /**
+      * Perform a big step change up.
+      */
+     public void bigVerticalDecrement() {
+         super.bigVerticalDecrement();
+         dirty = true;
+     }
+     /**
+      * Perform a big step change down.
+      */
+     public void bigVerticalIncrement() {
+         super.bigVerticalIncrement();
+         dirty = true;
+     }
+     /**
+      * Go to the top edge of the vertical scroller.
+      */
+     public void toTop() {
+         super.toTop();
+         dirty = true;
+     }
+     /**
+      * Go to the bottom edge of the vertical scroller.
+      */
+     public void toBottom() {
+         super.toBottom();
+         dirty = true;
+     }
+     /**
+      * Handle widget close.
+      */
+     @Override
+     public void close() {
+         if (emulator != null) {
+             emulator.close();
+         }
+         if (shell != null) {
+             terminateShellChildProcess();
+             shell.destroy();
+             shell = null;
+         }
+     }
+     /**
+      * Resize scrollbars for a new width/height.
+      */
+     @Override
+     public void reflowData() {
+         if (emulator == null) {
+             return;
+         }
+         // Synchronize against the emulator so we don't stomp on its reader
+         // thread.
+         synchronized (emulator) {
+             // Pull cursor information
+             readEmulatorState();
+             // Vertical scrollbar
+             setTopValue(getHeight()
+                 - (emulator.getScrollbackBuffer().size()
+                     + emulator.getDisplayBuffer().size()));
+             setVerticalBigChange(getHeight());
+         } // synchronized (emulator)
+     }
+     // ------------------------------------------------------------------------
+     // TTerminalWidget --------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Get the desired window title.
+      *
+      * @return the title
+      */
+     public String getTitle() {
+         return title;
+     }
+     /**
+      * Returns true if this widget does not want the application-wide mouse
+      * cursor drawn over it.
+      *
+      * @return true if this widget does not want the application-wide mouse
+      * cursor drawn over it
+      */
+     public boolean hasHiddenMouse() {
+         if (emulator == null) {
+             return false;
+         }
+         return (emulator.hasHiddenMousePointer() || typingHidMouse);
+     }
+     /**
+      * See if the terminal is still running.
+      *
+      * @return if true, we are still connected to / reading from the remote
+      * side
+      */
+     public boolean isReading() {
+         if (emulator == null) {
+             return false;
+         }
+         return emulator.isReading();
+     }
+     /**
+      * Convert a string array to a whitespace-separated string.
+      *
+      * @param array the string array
+      * @return a single string
+      */
+     private String stringArrayToString(final String [] array) {
+         StringBuilder sb = new StringBuilder(array[0].length());
+         for (int i = 0; i < array.length; i++) {
+             sb.append(array[i]);
+             if (i < array.length - 1) {
+                 sb.append(' ');
+             }
+         }
+         return sb.toString();
+     }
+     /**
+      * Spawn the shell.
+      *
+      * @param command the command line to execute
+      */
+     private void spawnShell(final String [] command) {
+         /*
+         System.err.printf("spawnShell(): '%s'\n",
+             stringArrayToString(command));
+         */
+         // We will have vScroller for its data fields and mouse event
+         // handling, but do not want to draw it.
+         vScroller = new TVScroller(null, getWidth(), 0, getHeight());
+         vScroller.setVisible(false);
+         setBottomValue(0);
+         title = i18n.getString("windowTitle");
+         // Assume XTERM
+         ECMA48.DeviceType deviceType = ECMA48.DeviceType.XTERM;
+         try {
+             ProcessBuilder pb = new ProcessBuilder(command);
+             Map<String, String> env = pb.environment();
+             env.put("TERM", ECMA48.deviceTypeTerm(deviceType));
+             env.put("LANG", ECMA48.deviceTypeLang(deviceType, "en"));
+             env.put("COLUMNS", "80");
+             env.put("LINES", "24");
+             pb.redirectErrorStream(true);
+             shell = pb.start();
+             emulator = new ECMA48(deviceType, shell.getInputStream(),
+                 shell.getOutputStream(), this);
+         } catch (IOException e) {
+             messageBox(i18n.getString("errorLaunchingShellTitle"),
+                 MessageFormat.format(i18n.getString("errorLaunchingShellText"),
+                     e.getMessage()));
+         }
+         // Setup the scroll bars
+         onResize(new TResizeEvent(TResizeEvent.Type.WIDGET, getWidth(),
+                 getHeight()));
+         // Hide mouse when typing option
+         if (System.getProperty("jexer.TTerminal.hideMouseWhenTyping",
+                 "true").equals("false")) {
+             hideMouseWhenTyping = false;
+         }
+     }
+     /**
+      * Terminate the child of the 'script' process used on POSIX.  This may
+      * or may not work.
+      */
+     private void terminateShellChildProcess() {
+         int pid = -1;
+         if (shell.getClass().getName().equals("java.lang.UNIXProcess")) {
+             /* get the PID on unix/linux systems */
+             try {
+                 Field field = shell.getClass().getDeclaredField("pid");
+                 field.setAccessible(true);
+                 pid = field.getInt(shell);
+             } catch (Throwable e) {
+                 // SQUASH, this didn't work.  Just bail out quietly.
+                 return;
+             }
+         }
+         if (pid != -1) {
+             // shell.destroy() works successfully at killing this side of
+             // 'script'.  But we need to make sure the other side (child
+             // process) is also killed.
+             String [] cmdKillIt = {
+                 "pkill", "-P", Integer.toString(pid)
+             };
+             try {
+                 Runtime.getRuntime().exec(cmdKillIt);
+             } catch (Throwable e) {
+                 // SQUASH, this didn't work.  Just bail out quietly.
+                 return;
+             }
+         }
+     }
+     /**
+      * Hook for subclasses to be notified of the shell termination.
+      */
+     public void onShellExit() {
+         TApplication app = getApplication();
+         if (app != null) {
+             if (closeAction != null) {
+                 // We have to put this action inside invokeLater() because it
+                 // could be executed during draw() when syncing with ECMA48.
+                 app.invokeLater(new Runnable() {
+                     public void run() {
+                         closeAction.DO(TTerminalWidget.this);
+                     }
+                 });
+             }
+             if (getApplication() != null) {
+                 getApplication().postEvent(new TMenuEvent(
+                     TMenu.MID_REPAINT));
+             }
+         }
+     }
+     /**
+      * Copy out variables from the emulator that TTerminal has to expose on
+      * screen.
+      */
+     private void readEmulatorState() {
+         if (emulator == null) {
+             return;
+         }
+         // Synchronize against the emulator so we don't stomp on its reader
+         // thread.
+         synchronized (emulator) {
+             setCursorX(emulator.getCursorX());
+             setCursorY(emulator.getCursorY()
+                 + (getHeight() - emulator.getHeight())
+                 - getVerticalValue());
+             setCursorVisible(emulator.isCursorVisible());
+             if (getCursorX() > getWidth()) {
+                 setCursorVisible(false);
+             }
+             if ((getCursorY() >= getHeight()) || (getCursorY() < 0)) {
+                 setCursorVisible(false);
+             }
+             if (emulator.getScreenTitle().length() > 0) {
+                 // Only update the title if the shell is still alive
+                 if (shell != null) {
+                     title = emulator.getScreenTitle();
+                 }
+             }
+             // Check to see if the shell has died.
+             if (!emulator.isReading() && (shell != null)) {
+                 try {
+                     int rc = shell.exitValue();
+                     // The emulator exited on its own, all is fine
+                     title = MessageFormat.format(i18n.
+                         getString("windowTitleCompleted"), title, rc);
+                     exitValue = rc;
+                     shell = null;
+                     emulator.close();
+                     onShellExit();
+                 } catch (IllegalThreadStateException e) {
+                     // The emulator thread has exited, but the shell Process
+                     // hasn't figured that out yet.  Do nothing, we will see
+                     // this in a future tick.
+                 }
+             } else if (emulator.isReading() && (shell != null)) {
+                 // The shell might be dead, let's check
+                 try {
+                     int rc = shell.exitValue();
+                     // If we got here, the shell died.
+                     title = MessageFormat.format(i18n.
+                         getString("windowTitleCompleted"), title, rc);
+                     exitValue = rc;
+                     shell = null;
+                     emulator.close();
+                     onShellExit();
+                 } catch (IllegalThreadStateException e) {
+                     // The shell is still running, do nothing.
+                 }
+             }
+         } // synchronized (emulator)
+     }
+     /**
+      * Check if a mouse press/release/motion event coordinate is over the
+      * emulator.
+      *
+      * @param mouse a mouse-based event
+      * @return whether or not the mouse is on the emulator
+      */
+     private boolean mouseOnEmulator(final TMouseEvent mouse) {
+         if (emulator == null) {
+             return false;
+         }
+         if (!emulator.isReading()) {
+             return false;
+         }
+         if ((mouse.getX() >= 0)
+             && (mouse.getX() < getWidth() - 1)
+             && (mouse.getY() >= 0)
+             && (mouse.getY() < getHeight())
+         ) {
+             return true;
+         }
+         return false;
+     }
+     /**
+      * Draw glyphs for a double-width or double-height VT100 cell to two
+      * screen cells.
+      *
+      * @param line the line this VT100 cell is in
+      * @param x the X position to draw the left half to
+      * @param y the Y position to draw to
+      * @param cell the cell to draw
+      */
+     private void putDoubleWidthCharXY(final DisplayLine line, final int x,
+         final int y, final Cell cell) {
+         int textWidth = getScreen().getTextWidth();
+         int textHeight = getScreen().getTextHeight();
+         boolean cursorBlinkVisible = true;
+         if (getScreen() instanceof SwingTerminal) {
+             SwingTerminal terminal = (SwingTerminal) getScreen();
+             cursorBlinkVisible = terminal.getCursorBlinkVisible();
+         } else if (getScreen() instanceof ECMA48Terminal) {
+             ECMA48Terminal terminal = (ECMA48Terminal) getScreen();
+             if (!terminal.hasSixel()) {
+                 // The backend does not have sixel support, draw this as text
+                 // and bail out.
+                 putCharXY(x, y, cell);
+                 putCharXY(x + 1, y, ' ', cell);
+                 return;
+             }
+             cursorBlinkVisible = blinkState;
+         } else {
+             // We don't know how to dray glyphs to this screen, draw them as
+             // text and bail out.
+             putCharXY(x, y, cell);
+             putCharXY(x + 1, y, ' ', cell);
+             return;
+         }
+         if ((textWidth != lastTextWidth) || (textHeight != lastTextHeight)) {
+             // Screen size has changed, reset the font.
+             setupFont(textHeight);
+             lastTextWidth = textWidth;
+             lastTextHeight = textHeight;
+         }
+         assert (doubleFont != null);
+         BufferedImage image;
+         if (line.getDoubleHeight() == 1) {
+             // Double-height top half: don't draw the underline.
+             Cell newCell = new Cell(cell);
+             newCell.setUnderline(false);
+             image = doubleFont.getImage(newCell, textWidth * 2, textHeight * 2,
+                 cursorBlinkVisible);
+         } else {
+             image = doubleFont.getImage(cell,  textWidth * 2, textHeight * 2,
+                 cursorBlinkVisible);
+         }
+         // Now that we have the double-wide glyph drawn, copy the right
+         // pieces of it to the cells.
+         Cell left = new Cell(cell);
+         Cell right = new Cell(cell);
+         right.setChar(' ');
+         BufferedImage leftImage = null;
+         BufferedImage rightImage = null;
+         /*
+         System.err.println("image " + image + " textWidth " + textWidth +
+             " textHeight " + textHeight);
+          */
+         switch (line.getDoubleHeight()) {
+         case 1:
+             // Top half double height
+             leftImage = image.getSubimage(0, 0, textWidth, textHeight);
+             rightImage = image.getSubimage(textWidth, 0, textWidth, textHeight);
+             break;
+         case 2:
+             // Bottom half double height
+             leftImage = image.getSubimage(0, textHeight, textWidth, textHeight);
+             rightImage = image.getSubimage(textWidth, textHeight,
+                 textWidth, textHeight);
+             break;
+         default:
+             // Either single height double-width, or error fallback
+             BufferedImage wideImage = new BufferedImage(textWidth * 2,
+                 textHeight, BufferedImage.TYPE_INT_ARGB);
+             Graphics2D grWide = wideImage.createGraphics();
+             grWide.drawImage(image, 0, 0, wideImage.getWidth(),
+                 wideImage.getHeight(), null);
+             grWide.dispose();
+             leftImage = wideImage.getSubimage(0, 0, textWidth, textHeight);
+             rightImage = wideImage.getSubimage(textWidth, 0, textWidth,
+                 textHeight);
+             break;
+         }
+         left.setImage(leftImage);
+         right.setImage(rightImage);
+         // Since we have image data, ditch the character here.  Otherwise, a
+         // drawBoxShadow() over the terminal window will show the characters
+         // which looks wrong.
+         left.setChar(' ');
+         right.setChar(' ');
+         putCharXY(x, y, left);
+         putCharXY(x + 1, y, right);
+     }
+     /**
+      * Set up the double-width font.
+      *
+      * @param fontSize the size of font to request for the single-width font.
+      * The double-width font will be 2x this value.
+      */
+     private void setupFont(final int fontSize) {
+         doubleFont = GlyphMaker.getInstance(fontSize * 2);
+         // Special case: the ECMA48 backend needs to have a timer to drive
+         // its blink state.
+         if (getScreen() instanceof jexer.backend.ECMA48Terminal) {
+             if (!haveTimer) {
+                 // Blink every 500 millis.
+                 long millis = 500;
+                 getApplication().addTimer(millis, true,
+                     new TAction() {
+                         public void DO() {
+                             blinkState = !blinkState;
+                             getApplication().doRepaint();
+                         }
+                     }
+                 );
+                 haveTimer = true;
+             }
+         }
+     }
+     // ------------------------------------------------------------------------
+     // DisplayListener --------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Called by emulator when fresh data has come in.
+      */
+     public void displayChanged() {
+         dirty = true;
+         getApplication().postEvent(new TMenuEvent(TMenu.MID_REPAINT));
+     }
+     /**
+      * Function to call to obtain the display width.
+      *
+      * @return the number of columns in the display
+      */
+     public int getDisplayWidth() {
+         if (ptypipe) {
+             return getWidth();
+         }
+         return 80;
+     }
+     /**
+      * Function to call to obtain the display height.
+      *
+      * @return the number of rows in the display
+      */
+     public int getDisplayHeight() {
+         if (ptypipe) {
+             return getHeight();
+         }
+         return 24;
+     }
+ }
index 0000000000000000000000000000000000000000,ecfcf21807745cb055d9452a79dd867f030e3ee6..ecfcf21807745cb055d9452a79dd867f030e3ee6
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,6 +1,6 @@@
+ windowTitle=Terminal
+ errorLaunchingShellTitle=Error
+ errorLaunchingShellText=Error launching shell: {0}
+ statusBarRunning=Terminal session executing...
+ windowTitleCompleted={0} [Completed - {1}]
+ statusBarCompleted=Terminal session completed, exit code {0}.
index 0000000000000000000000000000000000000000,e96c50c9921d99603da342e80802c27d0da02e86..e96c50c9921d99603da342e80802c27d0da02e86
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,455 +1,455 @@@
+ /*
+  * Jexer - Java Text User Interface
+  *
+  * The MIT License (MIT)
+  *
+  * 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"),
+  * to deal in the Software without restriction, including without limitation
+  * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+  * and/or sell copies of the Software, and to permit persons to whom the
+  * Software is furnished to do so, subject to the following conditions:
+  *
+  * The above copyright notice and this permission notice shall be included in
+  * all copies or substantial portions of the Software.
+  *
+  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+  * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+  * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+  * DEALINGS IN THE SOFTWARE.
+  *
+  * @author Kevin Lamonte [kevin.lamonte@gmail.com]
+  * @version 1
+  */
+ package jexer;
+ import java.awt.Font;
+ import java.awt.FontMetrics;
+ import java.awt.Graphics2D;
+ import java.awt.image.BufferedImage;
+ import java.io.InputStream;
+ import java.io.IOException;
+ import java.lang.reflect.Field;
+ import java.text.MessageFormat;
+ import java.util.ArrayList;
+ import java.util.HashMap;
+ import java.util.List;
+ import java.util.Map;
+ import java.util.ResourceBundle;
+ import jexer.backend.ECMA48Terminal;
+ import jexer.backend.GlyphMaker;
+ import jexer.backend.MultiScreen;
+ import jexer.backend.SwingTerminal;
+ import jexer.bits.Cell;
+ import jexer.bits.CellAttributes;
+ import jexer.event.TKeypressEvent;
+ import jexer.event.TMenuEvent;
+ import jexer.event.TMouseEvent;
+ import jexer.event.TResizeEvent;
+ import jexer.menu.TMenu;
+ import jexer.tterminal.DisplayLine;
+ import jexer.tterminal.DisplayListener;
+ import jexer.tterminal.ECMA48;
+ import static jexer.TKeypress.*;
+ /**
+  * TTerminalWindow exposes a ECMA-48 / ANSI X3.64 style terminal in a window.
+  */
+ public class TTerminalWindow extends TScrollableWindow {
+     /**
+      * Translated strings.
+      */
+     private static final ResourceBundle i18n = ResourceBundle.getBundle(TTerminalWindow.class.getName());
+     // ------------------------------------------------------------------------
+     // Variables --------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * The terminal.
+      */
+     private TTerminalWidget terminal;
+     /**
+      * If true, close the window when the shell exits.
+      */
+     private boolean closeOnExit = false;
+     // ------------------------------------------------------------------------
+     // Constructors -----------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Public constructor spawns a custom command line.
+      *
+      * @param application TApplication that manages this window
+      * @param x column relative to parent
+      * @param y row relative to parent
+      * @param commandLine the command line to execute
+      */
+     public TTerminalWindow(final TApplication application, final int x,
+         final int y, final String commandLine) {
+         this(application, x, y, RESIZABLE, commandLine.split("\\s+"),
+             System.getProperty("jexer.TTerminal.closeOnExit",
+                 "false").equals("true"));
+     }
+     /**
+      * Public constructor spawns a custom command line.
+      *
+      * @param application TApplication that manages this window
+      * @param x column relative to parent
+      * @param y row relative to parent
+      * @param commandLine the command line to execute
+      * @param closeOnExit if true, close the window when the command exits
+      */
+     public TTerminalWindow(final TApplication application, final int x,
+         final int y, final String commandLine, final boolean closeOnExit) {
+         this(application, x, y, RESIZABLE, commandLine.split("\\s+"),
+             closeOnExit);
+     }
+     /**
+      * Public constructor spawns a custom command line.
+      *
+      * @param application TApplication that manages this window
+      * @param x column relative to parent
+      * @param y row relative to parent
+      * @param flags mask of CENTERED, MODAL, or RESIZABLE
+      * @param command the command line to execute
+      */
+     public TTerminalWindow(final TApplication application, final int x,
+         final int y, final int flags, final String [] command) {
+         this(application, x, y, flags, command,
+             System.getProperty("jexer.TTerminal.closeOnExit",
+                 "false").equals("true"));
+     }
+     /**
+      * Public constructor spawns a custom command line.
+      *
+      * @param application TApplication that manages this window
+      * @param x column relative to parent
+      * @param y row relative to parent
+      * @param flags mask of CENTERED, MODAL, or RESIZABLE
+      * @param command the command line to execute
+      * @param closeOnExit if true, close the window when the command exits
+      */
+     public TTerminalWindow(final TApplication application, final int x,
+         final int y, final int flags, final String [] command,
+         final boolean closeOnExit) {
+         super(application, i18n.getString("windowTitle"), x, y,
+             80 + 2, 24 + 2, flags);
+         // Require at least one line for the display.
+         setMinimumWindowHeight(3);
+         this.closeOnExit = closeOnExit;
+         vScroller = new TVScroller(this, getWidth() - 2, 0, getHeight() - 2);
+         // Claim the keystrokes the emulator will need.
+         addShortcutKeys();
+         // Add shortcut text
+         newStatusBar(i18n.getString("statusBarRunning"));
+         // Spin it up
+         terminal = new TTerminalWidget(this, 0, 0, new TAction() {
+             public void DO() {
+                 onShellExit();
+             }
+         });
+     }
+     /**
+      * Public constructor spawns a shell.
+      *
+      * @param application TApplication that manages this window
+      * @param x column relative to parent
+      * @param y row relative to parent
+      * @param flags mask of CENTERED, MODAL, or RESIZABLE
+      */
+     public TTerminalWindow(final TApplication application, final int x,
+         final int y, final int flags) {
+         this(application, x, y, flags,
+             System.getProperty("jexer.TTerminal.closeOnExit",
+                 "false").equals("true"));
+     }
+     /**
+      * Public constructor spawns a shell.
+      *
+      * @param application TApplication that manages this window
+      * @param x column relative to parent
+      * @param y row relative to parent
+      * @param flags mask of CENTERED, MODAL, or RESIZABLE
+      * @param closeOnExit if true, close the window when the shell exits
+      */
+     public TTerminalWindow(final TApplication application, final int x,
+         final int y, final int flags, final boolean closeOnExit) {
+         super(application, i18n.getString("windowTitle"), x, y,
+             80 + 2, 24 + 2, flags);
+         // Require at least one line for the display.
+         setMinimumWindowHeight(3);
+         this.closeOnExit = closeOnExit;
+         vScroller = new TVScroller(this, getWidth() - 2, 0, getHeight() - 2);
+         // Claim the keystrokes the emulator will need.
+         addShortcutKeys();
+         // Add shortcut text
+         newStatusBar(i18n.getString("statusBarRunning"));
+         // Spin it up
+         terminal = new TTerminalWidget(this, 0, 0, new TAction() {
+             public void DO() {
+                 onShellExit();
+             }
+         });
+     }
+     // ------------------------------------------------------------------------
+     // TScrollableWindow ------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Draw the display buffer.
+      */
+     @Override
+     public void draw() {
+         if (terminal != null) {
+             setTitle(terminal.getTitle());
+         }
+         reflowData();
+         super.draw();
+     }
+     /**
+      * Handle window/screen resize events.
+      *
+      * @param resize resize event
+      */
+     @Override
+     public void onResize(final TResizeEvent resize) {
+         if (resize.getType() == TResizeEvent.Type.WIDGET) {
+             if (terminal != null) {
+                 terminal.onResize(new TResizeEvent(TResizeEvent.Type.WIDGET,
+                         getWidth() - 2, getHeight() - 2));
+             }
+             // Resize the scroll bars
+             reflowData();
+             placeScrollbars();
+         }
+         return;
+     }
+     /**
+      * Resize scrollbars for a new width/height.
+      */
+     @Override
+     public void reflowData() {
+         // Vertical scrollbar
+         if (terminal != null) {
+             terminal.reflowData();
+             setTopValue(terminal.getTopValue());
+             setBottomValue(terminal.getBottomValue());
+             setVerticalBigChange(terminal.getVerticalBigChange());
+             setVerticalValue(terminal.getVerticalValue());
+         }
+     }
+     /**
+      * Handle keystrokes.
+      *
+      * @param keypress keystroke event
+      */
+     @Override
+     public void onKeypress(final TKeypressEvent keypress) {
+         if ((terminal != null) && (terminal.isReading())) {
+             terminal.onKeypress(keypress);
+         } else {
+             super.onKeypress(keypress);
+         }
+     }
+     /**
+      * Handle mouse press events.
+      *
+      * @param mouse mouse button press event
+      */
+     @Override
+     public void onMouseDown(final TMouseEvent mouse) {
+         if (inWindowMove || inWindowResize) {
+             // TWindow needs to deal with this.
+             super.onMouseDown(mouse);
+             return;
+         }
+         super.onMouseDown(mouse);
+     }
+     /**
+      * Handle mouse release events.
+      *
+      * @param mouse mouse button release event
+      */
+     @Override
+     public void onMouseUp(final TMouseEvent mouse) {
+         if (inWindowMove || inWindowResize) {
+             // TWindow needs to deal with this.
+             super.onMouseUp(mouse);
+             return;
+         }
+         super.onMouseUp(mouse);
+         if (mouse.isMouse1() && mouseOnVerticalScroller(mouse)) {
+             // Clicked on vertical scrollbar
+             if (terminal != null) {
+                 terminal.setVerticalValue(getVerticalValue());
+             }
+         }
+     }
+     /**
+      * Handle mouse motion events.
+      *
+      * @param mouse mouse motion event
+      */
+     @Override
+     public void onMouseMotion(final TMouseEvent mouse) {
+         if (inWindowMove || inWindowResize) {
+             // TWindow needs to deal with this.
+             super.onMouseMotion(mouse);
+             return;
+         }
+         super.onMouseMotion(mouse);
+         if (mouse.isMouse1() && mouseOnVerticalScroller(mouse)) {
+             // Clicked/dragged on vertical scrollbar
+             if (terminal != null) {
+                 terminal.setVerticalValue(getVerticalValue());
+             }
+         }
+     }
+     // ------------------------------------------------------------------------
+     // TTerminalWindow --------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Returns true if this window does not want the application-wide mouse
+      * cursor drawn over it.
+      *
+      * @return true if this window does not want the application-wide mouse
+      * cursor drawn over it
+      */
+     @Override
+     public boolean hasHiddenMouse() {
+         if (terminal != null) {
+             return terminal.hasHiddenMouse();
+         }
+         return false;
+     }
+     /**
+      * Claim the keystrokes the emulator will need.
+      */
+     private void addShortcutKeys() {
+         addShortcutKeypress(kbCtrlA);
+         addShortcutKeypress(kbCtrlB);
+         addShortcutKeypress(kbCtrlC);
+         addShortcutKeypress(kbCtrlD);
+         addShortcutKeypress(kbCtrlE);
+         addShortcutKeypress(kbCtrlF);
+         addShortcutKeypress(kbCtrlG);
+         addShortcutKeypress(kbCtrlH);
+         addShortcutKeypress(kbCtrlU);
+         addShortcutKeypress(kbCtrlJ);
+         addShortcutKeypress(kbCtrlK);
+         addShortcutKeypress(kbCtrlL);
+         addShortcutKeypress(kbCtrlM);
+         addShortcutKeypress(kbCtrlN);
+         addShortcutKeypress(kbCtrlO);
+         addShortcutKeypress(kbCtrlP);
+         addShortcutKeypress(kbCtrlQ);
+         addShortcutKeypress(kbCtrlR);
+         addShortcutKeypress(kbCtrlS);
+         addShortcutKeypress(kbCtrlT);
+         addShortcutKeypress(kbCtrlU);
+         addShortcutKeypress(kbCtrlV);
+         addShortcutKeypress(kbCtrlW);
+         addShortcutKeypress(kbCtrlX);
+         addShortcutKeypress(kbCtrlY);
+         addShortcutKeypress(kbCtrlZ);
+         addShortcutKeypress(kbF1);
+         addShortcutKeypress(kbF2);
+         addShortcutKeypress(kbF3);
+         addShortcutKeypress(kbF4);
+         addShortcutKeypress(kbF5);
+         addShortcutKeypress(kbF6);
+         addShortcutKeypress(kbF7);
+         addShortcutKeypress(kbF8);
+         addShortcutKeypress(kbF9);
+         addShortcutKeypress(kbF10);
+         addShortcutKeypress(kbF11);
+         addShortcutKeypress(kbF12);
+         addShortcutKeypress(kbAltA);
+         addShortcutKeypress(kbAltB);
+         addShortcutKeypress(kbAltC);
+         addShortcutKeypress(kbAltD);
+         addShortcutKeypress(kbAltE);
+         addShortcutKeypress(kbAltF);
+         addShortcutKeypress(kbAltG);
+         addShortcutKeypress(kbAltH);
+         addShortcutKeypress(kbAltU);
+         addShortcutKeypress(kbAltJ);
+         addShortcutKeypress(kbAltK);
+         addShortcutKeypress(kbAltL);
+         addShortcutKeypress(kbAltM);
+         addShortcutKeypress(kbAltN);
+         addShortcutKeypress(kbAltO);
+         addShortcutKeypress(kbAltP);
+         addShortcutKeypress(kbAltQ);
+         addShortcutKeypress(kbAltR);
+         addShortcutKeypress(kbAltS);
+         addShortcutKeypress(kbAltT);
+         addShortcutKeypress(kbAltU);
+         addShortcutKeypress(kbAltV);
+         addShortcutKeypress(kbAltW);
+         addShortcutKeypress(kbAltX);
+         addShortcutKeypress(kbAltY);
+         addShortcutKeypress(kbAltZ);
+     }
+     /**
+      * Hook for subclasses to be notified of the shell termination.
+      */
+     public void onShellExit() {
+         if (closeOnExit) {
+             close();
+         }
+         clearShortcutKeypresses();
+         getApplication().postEvent(new TMenuEvent(TMenu.MID_REPAINT));
+     }
+ }
index 0000000000000000000000000000000000000000,ed22f492a49257944595dc19d53cbb2f3d3d9429..ed22f492a49257944595dc19d53cbb2f3d3d9429
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,2 +1,2 @@@
+ windowTitle=Terminal
+ statusBarRunning=Terminal session executing...
diff --combined src/jexer/TText.java
index 0000000000000000000000000000000000000000,22bc4b89051d31e586a60b91a274b55d3292bade..22bc4b89051d31e586a60b91a274b55d3292bade
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,445 +1,445 @@@
+ /*
+  * Jexer - Java Text User Interface
+  *
+  * The MIT License (MIT)
+  *
+  * 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"),
+  * to deal in the Software without restriction, including without limitation
+  * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+  * and/or sell copies of the Software, and to permit persons to whom the
+  * Software is furnished to do so, subject to the following conditions:
+  *
+  * The above copyright notice and this permission notice shall be included in
+  * all copies or substantial portions of the Software.
+  *
+  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+  * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+  * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+  * DEALINGS IN THE SOFTWARE.
+  *
+  * @author Kevin Lamonte [kevin.lamonte@gmail.com]
+  * @version 1
+  */
+ package jexer;
+ import java.util.Arrays;
+ import java.util.LinkedList;
+ import java.util.List;
+ import jexer.bits.CellAttributes;
+ import jexer.bits.StringUtils;
+ import jexer.event.TKeypressEvent;
+ import jexer.event.TMouseEvent;
+ import static jexer.TKeypress.kbDown;
+ import static jexer.TKeypress.kbEnd;
+ import static jexer.TKeypress.kbHome;
+ import static jexer.TKeypress.kbLeft;
+ import static jexer.TKeypress.kbPgDn;
+ import static jexer.TKeypress.kbPgUp;
+ import static jexer.TKeypress.kbRight;
+ import static jexer.TKeypress.kbUp;
+ /**
+  * TText implements a simple scrollable text area. It reflows automatically on
+  * resize.
+  */
+ public class TText extends TScrollableWidget {
+     // ------------------------------------------------------------------------
+     // Constants --------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Available text justifications.
+      */
+     public enum Justification {
+         /**
+          * Not justified at all, use spacing as provided by the client.
+          */
+         NONE,
+         /**
+          * Left-justified text.
+          */
+         LEFT,
+         /**
+          * Centered text.
+          */
+         CENTER,
+         /**
+          * Right-justified text.
+          */
+         RIGHT,
+         /**
+          * Fully-justified text.
+          */
+         FULL,
+     }
+     // ------------------------------------------------------------------------
+     // Variables --------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * How to justify the text.
+      */
+     private Justification justification = Justification.LEFT;
+     /**
+      * Text to display.
+      */
+     private String text;
+     /**
+      * Text converted to lines.
+      */
+     private List<String> lines;
+     /**
+      * Text color.
+      */
+     private String colorKey;
+     /**
+      * Maximum width of a single line.
+      */
+     private int maxLineWidth;
+     /**
+      * Number of lines between each paragraph.
+      */
+     private int lineSpacing = 1;
+     // ------------------------------------------------------------------------
+     // Constructors -----------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Public constructor.
+      *
+      * @param parent parent widget
+      * @param text text on the screen
+      * @param x column relative to parent
+      * @param y row relative to parent
+      * @param width width of text area
+      * @param height height of text area
+      */
+     public TText(final TWidget parent, final String text, final int x,
+             final int y, final int width, final int height) {
+         this(parent, text, x, y, width, height, "ttext");
+     }
+     /**
+      * Public constructor.
+      *
+      * @param parent parent widget
+      * @param text text on the screen
+      * @param x column relative to parent
+      * @param y row relative to parent
+      * @param width width of text area
+      * @param height height of text area
+      * @param colorKey ColorTheme key color to use for foreground
+      * text. Default is "ttext".
+      */
+     public TText(final TWidget parent, final String text, final int x,
+             final int y, final int width, final int height,
+             final String colorKey) {
+         // Set parent and window
+         super(parent, x, y, width, height);
+         this.text = text;
+         this.colorKey = colorKey;
+         lines = new LinkedList<String>();
+         vScroller = new TVScroller(this, getWidth() - 1, 0,
+             Math.max(1, getHeight() - 1));
+         hScroller = new THScroller(this, 0, getHeight() - 1,
+             Math.max(1, getWidth() - 1));
+         reflowData();
+     }
+     // ------------------------------------------------------------------------
+     // TScrollableWidget ------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Override TWidget's width: we need to set child widget widths.
+      *
+      * @param width new widget width
+      */
+     @Override
+     public void setWidth(final int width) {
+         super.setWidth(width);
+         if (hScroller != null) {
+             hScroller.setWidth(getWidth() - 1);
+         }
+         if (vScroller != null) {
+             vScroller.setX(getWidth() - 1);
+         }
+     }
+     /**
+      * Override TWidget's height: we need to set child widget heights.
+      * time.
+      *
+      * @param height new widget height
+      */
+     @Override
+     public void setHeight(final int height) {
+         super.setHeight(height);
+         if (hScroller != null) {
+             hScroller.setY(getHeight() - 1);
+         }
+         if (vScroller != null) {
+             vScroller.setHeight(getHeight() - 1);
+         }
+     }
+     /**
+      * Draw the text box.
+      */
+     @Override
+     public void draw() {
+         // Setup my color
+         CellAttributes color = getTheme().getColor(colorKey);
+         int begin = vScroller.getValue();
+         int topY = 0;
+         for (int i = begin; i < lines.size(); i++) {
+             String line = lines.get(i);
+             if (hScroller.getValue() < StringUtils.width(line)) {
+                 line = line.substring(hScroller.getValue());
+             } else {
+                 line = "";
+             }
+             if (getWidth() > 3) {
+                 String formatString = "%-" + Integer.toString(getWidth() - 1) + "s";
+                 putStringXY(0, topY, String.format(formatString, line), color);
+             }
+             topY++;
+             if (topY >= (getHeight() - 1)) {
+                 break;
+             }
+         }
+         // Pad the rest with blank lines
+         for (int i = topY; i < (getHeight() - 1); i++) {
+             hLineXY(0, i, getWidth() - 1, ' ', color);
+         }
+     }
+     /**
+      * Handle mouse press events.
+      *
+      * @param mouse mouse button press event
+      */
+     @Override
+     public void onMouseDown(final TMouseEvent mouse) {
+         if (mouse.isMouseWheelUp()) {
+             vScroller.decrement();
+             return;
+         }
+         if (mouse.isMouseWheelDown()) {
+             vScroller.increment();
+             return;
+         }
+         // Pass to children
+         super.onMouseDown(mouse);
+     }
+     /**
+      * Handle keystrokes.
+      *
+      * @param keypress keystroke event
+      */
+     @Override
+     public void onKeypress(final TKeypressEvent keypress) {
+         if (keypress.equals(kbLeft)) {
+             hScroller.decrement();
+         } else if (keypress.equals(kbRight)) {
+             hScroller.increment();
+         } else if (keypress.equals(kbUp)) {
+             vScroller.decrement();
+         } else if (keypress.equals(kbDown)) {
+             vScroller.increment();
+         } else if (keypress.equals(kbPgUp)) {
+             vScroller.bigDecrement();
+         } else if (keypress.equals(kbPgDn)) {
+             vScroller.bigIncrement();
+         } else if (keypress.equals(kbHome)) {
+             vScroller.toTop();
+         } else if (keypress.equals(kbEnd)) {
+             vScroller.toBottom();
+         } else {
+             // Pass other keys (tab etc.) on
+             super.onKeypress(keypress);
+         }
+     }
+     /**
+      * Resize text and scrollbars for a new width/height.
+      */
+     @Override
+     public void reflowData() {
+         // Reset the lines
+         lines.clear();
+         // Break up text into paragraphs
+         String[] paragraphs = text.split("\n\n");
+         for (String p : paragraphs) {
+             switch (justification) {
+             case NONE:
+                 lines.addAll(Arrays.asList(p.split("\n")));
+                 break;
+             case LEFT:
+                 lines.addAll(jexer.bits.StringUtils.left(p,
+                         getWidth() - 1));
+                 break;
+             case CENTER:
+                 lines.addAll(jexer.bits.StringUtils.center(p,
+                         getWidth() - 1));
+                 break;
+             case RIGHT:
+                 lines.addAll(jexer.bits.StringUtils.right(p,
+                         getWidth() - 1));
+                 break;
+             case FULL:
+                 lines.addAll(jexer.bits.StringUtils.full(p,
+                         getWidth() - 1));
+                 break;
+             }
+             for (int i = 0; i < lineSpacing; i++) {
+                 lines.add("");
+             }
+         }
+         computeBounds();
+     }
+     // ------------------------------------------------------------------------
+     // TText ------------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Set the text.
+      *
+      * @param text new text to display
+      */
+     public void setText(final String text) {
+         this.text = text;
+         reflowData();
+     }
+     /**
+      * Get the text.
+      *
+      * @return the text
+      */
+     public String getText() {
+         return text;
+     }
+     /**
+      * Convenience method used by TWindowLoggerOutput.
+      *
+      * @param line new line to add
+      */
+     public void addLine(final String line) {
+         if (StringUtils.width(text) == 0) {
+             text = line;
+         } else {
+             text += "\n\n";
+             text += line;
+         }
+         reflowData();
+     }
+     /**
+      * Recompute the bounds for the scrollbars.
+      */
+     private void computeBounds() {
+         maxLineWidth = 0;
+         for (String line : lines) {
+             if (StringUtils.width(line) > maxLineWidth) {
+                 maxLineWidth = StringUtils.width(line);
+             }
+         }
+         vScroller.setTopValue(0);
+         vScroller.setBottomValue((lines.size() - getHeight()) + 1);
+         if (vScroller.getBottomValue() < 0) {
+             vScroller.setBottomValue(0);
+         }
+         if (vScroller.getValue() > vScroller.getBottomValue()) {
+             vScroller.setValue(vScroller.getBottomValue());
+         }
+         hScroller.setLeftValue(0);
+         hScroller.setRightValue((maxLineWidth - getWidth()) + 1);
+         if (hScroller.getRightValue() < 0) {
+             hScroller.setRightValue(0);
+         }
+         if (hScroller.getValue() > hScroller.getRightValue()) {
+             hScroller.setValue(hScroller.getRightValue());
+         }
+     }
+     /**
+      * Set justification.
+      *
+      * @param justification LEFT, CENTER, RIGHT, or FULL
+      */
+     public void setJustification(final Justification justification) {
+         this.justification = justification;
+         reflowData();
+     }
+     /**
+      * Left-justify the text.
+      */
+     public void leftJustify() {
+         justification = Justification.LEFT;
+         reflowData();
+     }
+     /**
+      * Center-justify the text.
+      */
+     public void centerJustify() {
+         justification = Justification.CENTER;
+         reflowData();
+     }
+     /**
+      * Right-justify the text.
+      */
+     public void rightJustify() {
+         justification = Justification.RIGHT;
+         reflowData();
+     }
+     /**
+      * Fully-justify the text.
+      */
+     public void fullJustify() {
+         justification = Justification.FULL;
+         reflowData();
+     }
+ }
diff --combined src/jexer/TTimer.java
index 0000000000000000000000000000000000000000,8007153d679543d17b74fc51cb14663c262ac866..8007153d679543d17b74fc51cb14663c262ac866
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,120 +1,120 @@@
+ /*
+  * Jexer - Java Text User Interface
+  *
+  * The MIT License (MIT)
+  *
+  * 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"),
+  * to deal in the Software without restriction, including without limitation
+  * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+  * and/or sell copies of the Software, and to permit persons to whom the
+  * Software is furnished to do so, subject to the following conditions:
+  *
+  * The above copyright notice and this permission notice shall be included in
+  * all copies or substantial portions of the Software.
+  *
+  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+  * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+  * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+  * DEALINGS IN THE SOFTWARE.
+  *
+  * @author Kevin Lamonte [kevin.lamonte@gmail.com]
+  * @version 1
+  */
+ package jexer;
+ import java.util.Date;
+ /**
+  * TTimer implements a simple timer.
+  */
+ public class TTimer {
+     // ------------------------------------------------------------------------
+     // Variables --------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * If true, re-schedule after every tick.  Note package private access.
+      */
+     boolean recurring = false;
+     /**
+      * Duration (in millis) between ticks if this is a recurring timer.
+      */
+     private long duration = 0;
+     /**
+      * The next time this timer needs to be ticked.
+      */
+     private Date nextTick;
+     /**
+      * The action to perfom on a tick.
+      */
+     private TAction action;
+     // ------------------------------------------------------------------------
+     // Constructors -----------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Package private constructor.
+      *
+      * @param duration number of milliseconds to wait between ticks
+      * @param recurring if true, re-schedule this timer after every tick
+      * @param action to perform on next tick
+      */
+     TTimer(final long duration, final boolean recurring, final TAction action) {
+         this.recurring = recurring;
+         this.duration  = duration;
+         this.action    = action;
+         Date now = new Date();
+         nextTick = new Date(now.getTime() + duration);
+     }
+     // ------------------------------------------------------------------------
+     // TTimer -----------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Get the next time this timer needs to be ticked.  Note package private
+      * access.
+      *
+      * @return time at which action should be called
+      */
+     Date getNextTick() {
+         return nextTick;
+     }
+     /**
+      * Set the recurring flag.
+      *
+      * @param recurring if true, re-schedule this timer after every tick
+      */
+     public void setRecurring(final boolean recurring) {
+         this.recurring = recurring;
+     }
+     /**
+      * Tick this timer.  Note package private access.
+      */
+     void tick() {
+         if (action != null) {
+             action.DO();
+         }
+         // Set next tick
+         Date ticked = new Date();
+         if (recurring) {
+             nextTick = new Date(ticked.getTime() + duration);
+         }
+     }
+ }
index 0000000000000000000000000000000000000000,444e058542c8d566be5395f2f51ee1e88ff57a28..444e058542c8d566be5395f2f51ee1e88ff57a28
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,402 +1,402 @@@
+ /*
+  * Jexer - Java Text User Interface
+  *
+  * The MIT License (MIT)
+  *
+  * 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"),
+  * to deal in the Software without restriction, including without limitation
+  * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+  * and/or sell copies of the Software, and to permit persons to whom the
+  * Software is furnished to do so, subject to the following conditions:
+  *
+  * The above copyright notice and this permission notice shall be included in
+  * all copies or substantial portions of the Software.
+  *
+  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+  * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+  * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+  * DEALINGS IN THE SOFTWARE.
+  *
+  * @author Kevin Lamonte [kevin.lamonte@gmail.com]
+  * @version 1
+  */
+ package jexer;
+ import jexer.bits.CellAttributes;
+ import jexer.bits.GraphicsChars;
+ import jexer.event.TMouseEvent;
+ /**
+  * TVScroller implements a simple vertical scroll bar.
+  */
+ public class TVScroller extends TWidget {
+     // ------------------------------------------------------------------------
+     // Variables --------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Value that corresponds to being on the top edge of the scroll bar.
+      */
+     private int topValue = 0;
+     /**
+      * Value that corresponds to being on the bottom edge of the scroll bar.
+      */
+     private int bottomValue = 100;
+     /**
+      * Current value of the scroll.
+      */
+     private int value = 0;
+     /**
+      * The increment for clicking on an arrow.
+      */
+     private int smallChange = 1;
+     /**
+      * The increment for clicking in the bar between the box and an arrow.
+      */
+     private int bigChange = 20;
+     /**
+      * When true, the user is dragging the scroll box.
+      */
+     private boolean inScroll = false;
+     // ------------------------------------------------------------------------
+     // Constructors -----------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Public constructor.
+      *
+      * @param parent parent widget
+      * @param x column relative to parent
+      * @param y row relative to parent
+      * @param height height of scroll bar
+      */
+     public TVScroller(final TWidget parent, final int x, final int y,
+         final int height) {
+         // Set parent and window
+         super(parent, x, y, 1, height);
+     }
+     // ------------------------------------------------------------------------
+     // Event handlers ---------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Handle mouse button releases.
+      *
+      * @param mouse mouse button release event
+      */
+     @Override
+     public void onMouseUp(final TMouseEvent mouse) {
+         if (bottomValue == topValue) {
+             return;
+         }
+         if (inScroll) {
+             inScroll = false;
+             return;
+         }
+         if ((mouse.getX() == 0)
+             && (mouse.getY() == 0)
+         ) {
+             // Clicked on the top arrow
+             decrement();
+             return;
+         }
+         if ((mouse.getX() == 0)
+             && (mouse.getY() == getHeight() - 1)
+         ) {
+             // Clicked on the bottom arrow
+             increment();
+             return;
+         }
+         if ((mouse.getX() == 0)
+             && (mouse.getY() > 0)
+             && (mouse.getY() < boxPosition())
+         ) {
+             // Clicked between the top arrow and the box
+             value -= bigChange;
+             if (value < topValue) {
+                 value = topValue;
+             }
+             return;
+         }
+         if ((mouse.getX() == 0)
+             && (mouse.getY() > boxPosition())
+             && (mouse.getY() < getHeight() - 1)
+         ) {
+             // Clicked between the box and the bottom arrow
+             value += bigChange;
+             if (value > bottomValue) {
+                 value = bottomValue;
+             }
+             return;
+         }
+     }
+     /**
+      * Handle mouse movement events.
+      *
+      * @param mouse mouse motion event
+      */
+     @Override
+     public void onMouseMotion(final TMouseEvent mouse) {
+         if (bottomValue == topValue) {
+             return;
+         }
+         if ((mouse.isMouse1())
+             && (inScroll)
+             && (mouse.getY() > 0)
+             && (mouse.getY() < getHeight() - 1)
+         ) {
+             // Recompute value based on new box position
+             value = (bottomValue - topValue)
+                 * (mouse.getY()) / (getHeight() - 3) + topValue;
+             if (value > bottomValue) {
+                 value = bottomValue;
+             }
+             if (value < topValue) {
+                 value = topValue;
+             }
+             return;
+         }
+         inScroll = false;
+     }
+     /**
+      * Handle mouse press events.
+      *
+      * @param mouse mouse button press event
+      */
+     @Override
+     public void onMouseDown(final TMouseEvent mouse) {
+         if (bottomValue == topValue) {
+             return;
+         }
+         if ((mouse.getX() == 0)
+             && (mouse.getY() == boxPosition())
+         ) {
+             inScroll = true;
+             return;
+         }
+     }
+     // ------------------------------------------------------------------------
+     // TWidget ----------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Draw a vertical scroll bar.
+      */
+     @Override
+     public void draw() {
+         CellAttributes arrowColor = getTheme().getColor("tscroller.arrows");
+         CellAttributes barColor = getTheme().getColor("tscroller.bar");
+         putCharXY(0, 0, GraphicsChars.CP437[0x1E], arrowColor);
+         putCharXY(0, getHeight() - 1, GraphicsChars.CP437[0x1F], arrowColor);
+         // Place the box
+         if (bottomValue > topValue) {
+             vLineXY(0, 1, getHeight() - 2, GraphicsChars.CP437[0xB1], barColor);
+             putCharXY(0, boxPosition(), GraphicsChars.BOX, arrowColor);
+         } else {
+             vLineXY(0, 1, getHeight() - 2, GraphicsChars.HATCH, barColor);
+         }
+     }
+     // ------------------------------------------------------------------------
+     // TVScroller -------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Get the value that corresponds to being on the top edge of the scroll
+      * bar.
+      *
+      * @return the scroll value
+      */
+     public int getTopValue() {
+         return topValue;
+     }
+     /**
+      * Set the value that corresponds to being on the top edge of the scroll
+      * bar.
+      *
+      * @param topValue the new scroll value
+      */
+     public void setTopValue(final int topValue) {
+         this.topValue = topValue;
+     }
+     /**
+      * Get the value that corresponds to being on the bottom edge of the
+      * scroll bar.
+      *
+      * @return the scroll value
+      */
+     public int getBottomValue() {
+         return bottomValue;
+     }
+     /**
+      * Set the value that corresponds to being on the bottom edge of the
+      * scroll bar.
+      *
+      * @param bottomValue the new scroll value
+      */
+     public void setBottomValue(final int bottomValue) {
+         this.bottomValue = bottomValue;
+     }
+     /**
+      * Get current value of the scroll.
+      *
+      * @return the scroll value
+      */
+     public int getValue() {
+         return value;
+     }
+     /**
+      * Set current value of the scroll.
+      *
+      * @param value the new scroll value
+      */
+     public void setValue(final int value) {
+         this.value = value;
+     }
+     /**
+      * Get the increment for clicking on an arrow.
+      *
+      * @return the increment value
+      */
+     public int getSmallChange() {
+         return smallChange;
+     }
+     /**
+      * Set the increment for clicking on an arrow.
+      *
+      * @param smallChange the new increment value
+      */
+     public void setSmallChange(final int smallChange) {
+         this.smallChange = smallChange;
+     }
+     /**
+      * Set the increment for clicking in the bar between the box and an
+      * arrow.
+      *
+      * @return the increment value
+      */
+     public int getBigChange() {
+         return bigChange;
+     }
+     /**
+      * Set the increment for clicking in the bar between the box and an
+      * arrow.
+      *
+      * @param bigChange the new increment value
+      */
+     public void setBigChange(final int bigChange) {
+         this.bigChange = bigChange;
+     }
+     /**
+      * Compute the position of the scroll box (a.k.a. grip, thumb).
+      *
+      * @return Y position of the box, between 1 and height - 2
+      */
+     private int boxPosition() {
+         return (getHeight() - 3) * (value - topValue) / (bottomValue - topValue) + 1;
+     }
+     /**
+      * Perform a small step change up.
+      */
+     public void decrement() {
+         if (bottomValue == topValue) {
+             return;
+         }
+         value -= smallChange;
+         if (value < topValue) {
+             value = topValue;
+         }
+     }
+     /**
+      * Perform a small step change down.
+      */
+     public void increment() {
+         if (bottomValue == topValue) {
+             return;
+         }
+         value += smallChange;
+         if (value > bottomValue) {
+             value = bottomValue;
+         }
+     }
+     /**
+      * Perform a big step change up.
+      */
+     public void bigDecrement() {
+         if (bottomValue == topValue) {
+             return;
+         }
+         value -= bigChange;
+         if (value < topValue) {
+             value = topValue;
+         }
+     }
+     /**
+      * Perform a big step change down.
+      */
+     public void bigIncrement() {
+         if (bottomValue == topValue) {
+             return;
+         }
+         value += bigChange;
+         if (value > bottomValue) {
+             value = bottomValue;
+         }
+     }
+     /**
+      * Go to the top edge of the scroller.
+      */
+     public void toTop() {
+         value = topValue;
+     }
+     /**
+      * Go to the bottom edge of the scroller.
+      */
+     public void toBottom() {
+         value = bottomValue;
+     }
+ }
diff --combined src/jexer/TWidget.java
index 0000000000000000000000000000000000000000,eb06175092d9a8dcd53c20a4d8e75b856d200967..eb06175092d9a8dcd53c20a4d8e75b856d200967
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,2767 +1,2767 @@@
+ /*
+  * Jexer - Java Text User Interface
+  *
+  * The MIT License (MIT)
+  *
+  * 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"),
+  * to deal in the Software without restriction, including without limitation
+  * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+  * and/or sell copies of the Software, and to permit persons to whom the
+  * Software is furnished to do so, subject to the following conditions:
+  *
+  * The above copyright notice and this permission notice shall be included in
+  * all copies or substantial portions of the Software.
+  *
+  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+  * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+  * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+  * DEALINGS IN THE SOFTWARE.
+  *
+  * @author Kevin Lamonte [kevin.lamonte@gmail.com]
+  * @version 1
+  */
+ package jexer;
+ import java.awt.image.BufferedImage;
+ import java.io.IOException;
+ import java.util.List;
+ import java.util.ArrayList;
+ import jexer.backend.Screen;
+ import jexer.bits.Cell;
+ import jexer.bits.CellAttributes;
+ import jexer.bits.ColorTheme;
+ import jexer.event.TCommandEvent;
+ import jexer.event.TInputEvent;
+ import jexer.event.TKeypressEvent;
+ import jexer.event.TMenuEvent;
+ import jexer.event.TMouseEvent;
+ import jexer.event.TResizeEvent;
+ import jexer.layout.LayoutManager;
+ import jexer.menu.TMenu;
+ import jexer.ttree.TTreeItem;
+ import jexer.ttree.TTreeView;
+ import jexer.ttree.TTreeViewWidget;
+ import static jexer.TKeypress.*;
+ /**
+  * TWidget is the base class of all objects that can be drawn on screen or
+  * handle user input events.
+  */
+ public abstract class TWidget implements Comparable<TWidget> {
+     // ------------------------------------------------------------------------
+     // Variables --------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Every widget has a parent widget that it may be "contained" in.  For
+      * example, a TWindow might contain several TFields, or a TComboBox may
+      * contain a TList that itself contains a TVScroller.
+      */
+     private TWidget parent = null;
+     /**
+      * Child widgets that this widget contains.
+      */
+     private List<TWidget> children;
+     /**
+      * The currently active child widget that will receive keypress events.
+      */
+     private TWidget activeChild = null;
+     /**
+      * If true, this widget will receive events.
+      */
+     private boolean active = false;
+     /**
+      * The window that this widget draws to.
+      */
+     private TWindow window = null;
+     /**
+      * Absolute X position of the top-left corner.
+      */
+     private int x = 0;
+     /**
+      * Absolute Y position of the top-left corner.
+      */
+     private int y = 0;
+     /**
+      * Width.
+      */
+     private int width = 0;
+     /**
+      * Height.
+      */
+     private int height = 0;
+     /**
+      * My tab order inside a window or containing widget.
+      */
+     private int tabOrder = 0;
+     /**
+      * If true, this widget can be tabbed to or receive events.
+      */
+     private boolean enabled = true;
+     /**
+      * If true, this widget will be rendered.
+      */
+     private boolean visible = true;
+     /**
+      * If true, this widget has a cursor.
+      */
+     private boolean cursorVisible = false;
+     /**
+      * Cursor column position in relative coordinates.
+      */
+     private int cursorX = 0;
+     /**
+      * Cursor row position in relative coordinates.
+      */
+     private int cursorY = 0;
+     /**
+      * Layout manager.
+      */
+     private LayoutManager layout = null;
+     // ------------------------------------------------------------------------
+     // Constructors -----------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Default constructor for subclasses.
+      */
+     protected TWidget() {
+         children = new ArrayList<TWidget>();
+     }
+     /**
+      * Protected constructor.
+      *
+      * @param parent parent widget
+      */
+     protected TWidget(final TWidget parent) {
+         this(parent, true);
+     }
+     /**
+      * Protected constructor.
+      *
+      * @param parent parent widget
+      * @param x column relative to parent
+      * @param y row relative to parent
+      * @param width width of widget
+      * @param height height of widget
+      */
+     protected TWidget(final TWidget parent, final int x, final int y,
+         final int width, final int height) {
+         this(parent, true, x, y, width, height);
+     }
+     /**
+      * Protected constructor used by subclasses that are disabled by default.
+      *
+      * @param parent parent widget
+      * @param enabled if true assume enabled
+      */
+     protected TWidget(final TWidget parent, final boolean enabled) {
+         this(parent, enabled, 0, 0, 0, 0);
+     }
+     /**
+      * Protected constructor used by subclasses that are disabled by default.
+      *
+      * @param parent parent widget
+      * @param enabled if true assume enabled
+      * @param x column relative to parent
+      * @param y row relative to parent
+      * @param width width of widget
+      * @param height height of widget
+      */
+     protected TWidget(final TWidget parent, final boolean enabled,
+         final int x, final int y, final int width, final int height) {
+         if (width < 0) {
+             throw new IllegalArgumentException("width cannot be negative");
+         }
+         if (height < 0) {
+             throw new IllegalArgumentException("height cannot be negative");
+         }
+         this.enabled = enabled;
+         this.parent = parent;
+         children = new ArrayList<TWidget>();
+         this.x = x;
+         this.y = y;
+         this.width = width;
+         this.height = height;
+         if (parent != null) {
+             this.window = parent.window;
+             parent.addChild(this);
+         }
+     }
+     /**
+      * Backdoor access for TWindow's constructor.  ONLY TWindow USES THIS.
+      *
+      * @param window the top-level window
+      * @param x column relative to parent
+      * @param y row relative to parent
+      * @param width width of window
+      * @param height height of window
+      */
+     protected final void setupForTWindow(final TWindow window,
+         final int x, final int y, final int width, final int height) {
+         if (width < 0) {
+             throw new IllegalArgumentException("width cannot be negative");
+         }
+         if (height < 0) {
+             throw new IllegalArgumentException("height cannot be negative");
+         }
+         this.parent = window;
+         this.window = window;
+         this.x      = x;
+         this.y      = y;
+         this.width  = width;
+         this.height = height;
+     }
+     // ------------------------------------------------------------------------
+     // Event handlers ---------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Subclasses should override this method to cleanup resources.  This is
+      * called by TWindow.onClose().
+      */
+     protected void close() {
+         // Default: call close() on children.
+         for (TWidget w: getChildren()) {
+             w.close();
+         }
+     }
+     /**
+      * Check if a mouse press/release event coordinate is contained in this
+      * widget.
+      *
+      * @param mouse a mouse-based event
+      * @return whether or not a mouse click would be sent to this widget
+      */
+     public final boolean mouseWouldHit(final TMouseEvent mouse) {
+         if (!enabled) {
+             return false;
+         }
+         if ((this instanceof TTreeItem)
+             && ((y < 0) || (y > parent.getHeight() - 1))
+         ) {
+             return false;
+         }
+         if ((mouse.getAbsoluteX() >= getAbsoluteX())
+             && (mouse.getAbsoluteX() <  getAbsoluteX() + width)
+             && (mouse.getAbsoluteY() >= getAbsoluteY())
+             && (mouse.getAbsoluteY() <  getAbsoluteY() + height)
+         ) {
+             return true;
+         }
+         return false;
+     }
+     /**
+      * Method that subclasses can override to handle keystrokes.
+      *
+      * @param keypress keystroke event
+      */
+     public void onKeypress(final TKeypressEvent keypress) {
+         assert (parent != null);
+         if ((children.size() == 0)
+             || (this instanceof TTreeView)
+             || (this instanceof TText)
+             || (this instanceof TComboBox)
+         ) {
+             // Defaults:
+             //   tab / shift-tab - switch to next/previous widget
+             //   left-arrow or up-arrow: same as shift-tab
+             if ((keypress.equals(kbTab))
+                 || (keypress.equals(kbDown) && !(this instanceof TComboBox))
+             ) {
+                 parent.switchWidget(true);
+                 return;
+             } else if ((keypress.equals(kbShiftTab))
+                 || (keypress.equals(kbBackTab))
+                 || (keypress.equals(kbUp) && !(this instanceof TComboBox))
+             ) {
+                 parent.switchWidget(false);
+                 return;
+             }
+         }
+         if ((children.size() == 0)
+             && !(this instanceof TTreeView)
+         ) {
+             // Defaults:
+             //   right-arrow or down-arrow: same as tab
+             if (keypress.equals(kbRight)) {
+                 parent.switchWidget(true);
+                 return;
+             } else if (keypress.equals(kbLeft)) {
+                 parent.switchWidget(false);
+                 return;
+             }
+         }
+         // If I have any buttons on me AND this is an Alt-key that matches
+         // its mnemonic, send it an Enter keystroke.
+         for (TWidget widget: children) {
+             if (widget instanceof TButton) {
+                 TButton button = (TButton) widget;
+                 if (button.isEnabled()
+                     && !keypress.getKey().isFnKey()
+                     && keypress.getKey().isAlt()
+                     && !keypress.getKey().isCtrl()
+                     && (Character.toLowerCase(button.getMnemonic().getShortcut())
+                         == Character.toLowerCase(keypress.getKey().getChar()))
+                 ) {
+                     widget.onKeypress(new TKeypressEvent(kbEnter));
+                     return;
+                 }
+             }
+         }
+         // If I have any labels on me AND this is an Alt-key that matches
+         // its mnemonic, call its action.
+         for (TWidget widget: children) {
+             if (widget instanceof TLabel) {
+                 TLabel label = (TLabel) widget;
+                 if (!keypress.getKey().isFnKey()
+                     && keypress.getKey().isAlt()
+                     && !keypress.getKey().isCtrl()
+                     && (Character.toLowerCase(label.getMnemonic().getShortcut())
+                         == Character.toLowerCase(keypress.getKey().getChar()))
+                 ) {
+                     label.dispatch();
+                     return;
+                 }
+             }
+         }
+         // If I have any radiobuttons on me AND this is an Alt-key that
+         // matches its mnemonic, select it and send a Space to it.
+         for (TWidget widget: children) {
+             if (widget instanceof TRadioButton) {
+                 TRadioButton button = (TRadioButton) widget;
+                 if (button.isEnabled()
+                     && !keypress.getKey().isFnKey()
+                     && keypress.getKey().isAlt()
+                     && !keypress.getKey().isCtrl()
+                     && (Character.toLowerCase(button.getMnemonic().getShortcut())
+                         == Character.toLowerCase(keypress.getKey().getChar()))
+                 ) {
+                     activate(widget);
+                     widget.onKeypress(new TKeypressEvent(kbSpace));
+                     return;
+                 }
+             }
+             if (widget instanceof TRadioGroup) {
+                 for (TWidget child: widget.getChildren()) {
+                     if (child instanceof TRadioButton) {
+                         TRadioButton button = (TRadioButton) child;
+                         if (button.isEnabled()
+                             && !keypress.getKey().isFnKey()
+                             && keypress.getKey().isAlt()
+                             && !keypress.getKey().isCtrl()
+                             && (Character.toLowerCase(button.getMnemonic().getShortcut())
+                                 == Character.toLowerCase(keypress.getKey().getChar()))
+                         ) {
+                             activate(widget);
+                             widget.activate(child);
+                             child.onKeypress(new TKeypressEvent(kbSpace));
+                             return;
+                         }
+                     }
+                 }
+             }
+         }
+         // If I have any checkboxes on me AND this is an Alt-key that matches
+         // its mnemonic, select it and set it to checked.
+         for (TWidget widget: children) {
+             if (widget instanceof TCheckBox) {
+                 TCheckBox checkBox = (TCheckBox) widget;
+                 if (checkBox.isEnabled()
+                     && !keypress.getKey().isFnKey()
+                     && keypress.getKey().isAlt()
+                     && !keypress.getKey().isCtrl()
+                     && (Character.toLowerCase(checkBox.getMnemonic().getShortcut())
+                         == Character.toLowerCase(keypress.getKey().getChar()))
+                 ) {
+                     activate(checkBox);
+                     checkBox.setChecked(true);
+                     return;
+                 }
+             }
+         }
+         // Dispatch the keypress to an active widget
+         for (TWidget widget: children) {
+             if (widget.active) {
+                 widget.onKeypress(keypress);
+                 return;
+             }
+         }
+     }
+     /**
+      * Method that subclasses can override to handle mouse button presses.
+      *
+      * @param mouse mouse button event
+      */
+     public void onMouseDown(final TMouseEvent mouse) {
+         // Default: do nothing, pass to children instead
+         if (activeChild != null) {
+             if (activeChild.mouseWouldHit(mouse)) {
+                 // Dispatch to the active child
+                 // Set x and y relative to the child's coordinates
+                 mouse.setX(mouse.getAbsoluteX() - activeChild.getAbsoluteX());
+                 mouse.setY(mouse.getAbsoluteY() - activeChild.getAbsoluteY());
+                 activeChild.onMouseDown(mouse);
+                 return;
+             }
+         }
+         for (int i = children.size() - 1 ; i >= 0 ; i--) {
+             TWidget widget = children.get(i);
+             if (widget.mouseWouldHit(mouse)) {
+                 // Dispatch to this child, also activate it
+                 activate(widget);
+                 // Set x and y relative to the child's coordinates
+                 mouse.setX(mouse.getAbsoluteX() - widget.getAbsoluteX());
+                 mouse.setY(mouse.getAbsoluteY() - widget.getAbsoluteY());
+                 widget.onMouseDown(mouse);
+                 return;
+             }
+         }
+     }
+     /**
+      * Method that subclasses can override to handle mouse button releases.
+      *
+      * @param mouse mouse button event
+      */
+     public void onMouseUp(final TMouseEvent mouse) {
+         // Default: do nothing, pass to children instead
+         if (activeChild != null) {
+             if (activeChild.mouseWouldHit(mouse)) {
+                 // Dispatch to the active child
+                 // Set x and y relative to the child's coordinates
+                 mouse.setX(mouse.getAbsoluteX() - activeChild.getAbsoluteX());
+                 mouse.setY(mouse.getAbsoluteY() - activeChild.getAbsoluteY());
+                 activeChild.onMouseUp(mouse);
+                 return;
+             }
+         }
+         for (int i = children.size() - 1 ; i >= 0 ; i--) {
+             TWidget widget = children.get(i);
+             if (widget.mouseWouldHit(mouse)) {
+                 // Dispatch to this child, also activate it
+                 activate(widget);
+                 // Set x and y relative to the child's coordinates
+                 mouse.setX(mouse.getAbsoluteX() - widget.getAbsoluteX());
+                 mouse.setY(mouse.getAbsoluteY() - widget.getAbsoluteY());
+                 widget.onMouseUp(mouse);
+                 return;
+             }
+         }
+     }
+     /**
+      * Method that subclasses can override to handle mouse movements.
+      *
+      * @param mouse mouse motion event
+      */
+     public void onMouseMotion(final TMouseEvent mouse) {
+         // Default: do nothing, pass it on to ALL of my children.  This way
+         // the children can see the mouse "leaving" their area.
+         for (TWidget widget: children) {
+             // Set x and y relative to the child's coordinates
+             mouse.setX(mouse.getAbsoluteX() - widget.getAbsoluteX());
+             mouse.setY(mouse.getAbsoluteY() - widget.getAbsoluteY());
+             widget.onMouseMotion(mouse);
+         }
+     }
+     /**
+      * Method that subclasses can override to handle mouse button
+      * double-clicks.
+      *
+      * @param mouse mouse button event
+      */
+     public void onMouseDoubleClick(final TMouseEvent mouse) {
+         // Default: do nothing, pass to children instead
+         if (activeChild != null) {
+             if (activeChild.mouseWouldHit(mouse)) {
+                 // Dispatch to the active child
+                 // Set x and y relative to the child's coordinates
+                 mouse.setX(mouse.getAbsoluteX() - activeChild.getAbsoluteX());
+                 mouse.setY(mouse.getAbsoluteY() - activeChild.getAbsoluteY());
+                 activeChild.onMouseDoubleClick(mouse);
+                 return;
+             }
+         }
+         for (int i = children.size() - 1 ; i >= 0 ; i--) {
+             TWidget widget = children.get(i);
+             if (widget.mouseWouldHit(mouse)) {
+                 // Dispatch to this child, also activate it
+                 activate(widget);
+                 // Set x and y relative to the child's coordinates
+                 mouse.setX(mouse.getAbsoluteX() - widget.getAbsoluteX());
+                 mouse.setY(mouse.getAbsoluteY() - widget.getAbsoluteY());
+                 widget.onMouseDoubleClick(mouse);
+                 return;
+             }
+         }
+     }
+     /**
+      * Method that subclasses can override to handle window/screen resize
+      * events.
+      *
+      * @param resize resize event
+      */
+     public void onResize(final TResizeEvent resize) {
+         // Default: change my width/height.
+         if (resize.getType() == TResizeEvent.Type.WIDGET) {
+             width = resize.getWidth();
+             height = resize.getHeight();
+             if (layout != null) {
+                 if (this instanceof TWindow) {
+                     layout.onResize(new TResizeEvent(TResizeEvent.Type.WIDGET,
+                             width - 2, height - 2));
+                 } else {
+                     layout.onResize(resize);
+                 }
+             }
+         } else {
+             // Let children see the screen resize
+             for (TWidget widget: children) {
+                 widget.onResize(resize);
+             }
+         }
+     }
+     /**
+      * Method that subclasses can override to handle posted command events.
+      *
+      * @param command command event
+      */
+     public void onCommand(final TCommandEvent command) {
+         // Default: do nothing, pass to children instead
+         for (TWidget widget: children) {
+             widget.onCommand(command);
+         }
+     }
+     /**
+      * Method that subclasses can override to handle menu or posted menu
+      * events.
+      *
+      * @param menu menu event
+      */
+     public void onMenu(final TMenuEvent menu) {
+         // Default: do nothing, pass to children instead
+         for (TWidget widget: children) {
+             widget.onMenu(menu);
+         }
+     }
+     /**
+      * Method that subclasses can override to do processing when the UI is
+      * idle.  Note that repainting is NOT assumed.  To get a refresh after
+      * onIdle, call doRepaint().
+      */
+     public void onIdle() {
+         // Default: do nothing, pass to children instead
+         for (TWidget widget: children) {
+             widget.onIdle();
+         }
+     }
+     /**
+      * Consume event.  Subclasses that want to intercept all events in one go
+      * can override this method.
+      *
+      * @param event keyboard, mouse, resize, command, or menu event
+      */
+     public void handleEvent(final TInputEvent event) {
+         /*
+         System.err.printf("TWidget (%s) event: %s\n", this.getClass().getName(),
+             event);
+         */
+         if (!enabled) {
+             // Discard event
+             // System.err.println("   -- discard --");
+             return;
+         }
+         if (event instanceof TKeypressEvent) {
+             onKeypress((TKeypressEvent) event);
+         } else if (event instanceof TMouseEvent) {
+             TMouseEvent mouse = (TMouseEvent) event;
+             switch (mouse.getType()) {
+             case MOUSE_DOWN:
+                 onMouseDown(mouse);
+                 break;
+             case MOUSE_UP:
+                 onMouseUp(mouse);
+                 break;
+             case MOUSE_MOTION:
+                 onMouseMotion(mouse);
+                 break;
+             case MOUSE_DOUBLE_CLICK:
+                 onMouseDoubleClick(mouse);
+                 break;
+             default:
+                 throw new IllegalArgumentException("Invalid mouse event type: "
+                     + mouse.getType());
+             }
+         } else if (event instanceof TResizeEvent) {
+             onResize((TResizeEvent) event);
+         } else if (event instanceof TCommandEvent) {
+             onCommand((TCommandEvent) event);
+         } else if (event instanceof TMenuEvent) {
+             onMenu((TMenuEvent) event);
+         }
+         // Do nothing else
+         return;
+     }
+     // ------------------------------------------------------------------------
+     // TWidget ----------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Get parent widget.
+      *
+      * @return parent widget
+      */
+     public final TWidget getParent() {
+         return parent;
+     }
+     /**
+      * Get the list of child widgets that this widget contains.
+      *
+      * @return the list of child widgets
+      */
+     public List<TWidget> getChildren() {
+         return children;
+     }
+     /**
+      * Remove this widget from its parent container.  close() will be called
+      * before it is removed.
+      */
+     public final void remove() {
+         remove(true);
+     }
+     /**
+      * Remove this widget from its parent container.
+      *
+      * @param doClose if true, call the close() method before removing the
+      * child
+      */
+     public final void remove(final boolean doClose) {
+         if (parent != null) {
+             parent.remove(this, doClose);
+         }
+     }
+     /**
+      * Remove a child widget from this container.
+      *
+      * @param child the child widget to remove
+      */
+     public final void remove(final TWidget child) {
+         remove(child, true);
+     }
+     /**
+      * Remove a child widget from this container.
+      *
+      * @param child the child widget to remove
+      * @param doClose if true, call the close() method before removing the
+      * child
+      */
+     public final void remove(final TWidget child, final boolean doClose) {
+         if (!children.contains(child)) {
+             throw new IndexOutOfBoundsException("child widget is not in " +
+                 "list of children of this parent");
+         }
+         if (doClose) {
+             child.close();
+         }
+         children.remove(child);
+         child.parent = null;
+         child.window = null;
+         if (layout != null) {
+             layout.remove(this);
+         }
+     }
+     /**
+      * Set this widget's parent to a different widget.
+      *
+      * @param newParent new parent widget
+      * @param doClose if true, call the close() method before removing the
+      * child from its existing parent widget
+      */
+     public final void setParent(final TWidget newParent,
+         final boolean doClose) {
+         if (parent != null) {
+             parent.remove(this, doClose);
+             window = null;
+         }
+         assert (parent == null);
+         assert (window == null);
+         parent = newParent;
+         setWindow(parent.window);
+         parent.addChild(this);
+     }
+     /**
+      * Set this widget's window to a specific window.
+      *
+      * Having a null parent with a specified window is only used within Jexer
+      * by TStatusBar because TApplication routes events directly to it and
+      * calls its draw() method.  Any other non-parented widgets will require
+      * similar special case functionality to receive events or be drawn to
+      * screen.
+      *
+      * @param window the window to use
+      */
+     public final void setWindow(final TWindow window) {
+         this.window = window;
+         for (TWidget child: getChildren()) {
+             child.setWindow(window);
+         }
+     }
+     /**
+      * Remove a child widget from this container, and all of its children
+      * recursively from their parent containers.
+      *
+      * @param child the child widget to remove
+      * @param doClose if true, call the close() method before removing each
+      * child
+      */
+     public final void removeAll(final TWidget child, final boolean doClose) {
+         remove(child, doClose);
+         for (TWidget w: child.children) {
+             child.removeAll(w, doClose);
+         }
+     }
+     /**
+      * Get active flag.
+      *
+      * @return if true, this widget will receive events
+      */
+     public final boolean isActive() {
+         return active;
+     }
+     /**
+      * Set active flag.
+      *
+      * @param active if true, this widget will receive events
+      */
+     public final void setActive(final boolean active) {
+         this.active = active;
+     }
+     /**
+      * Get the window this widget is on.
+      *
+      * @return the window
+      */
+     public final TWindow getWindow() {
+         return window;
+     }
+     /**
+      * Get X position.
+      *
+      * @return absolute X position of the top-left corner
+      */
+     public final int getX() {
+         return x;
+     }
+     /**
+      * Set X position.
+      *
+      * @param x absolute X position of the top-left corner
+      */
+     public final void setX(final int x) {
+         this.x = x;
+     }
+     /**
+      * Get Y position.
+      *
+      * @return absolute Y position of the top-left corner
+      */
+     public final int getY() {
+         return y;
+     }
+     /**
+      * Set Y position.
+      *
+      * @param y absolute Y position of the top-left corner
+      */
+     public final void setY(final int y) {
+         this.y = y;
+     }
+     /**
+      * Get the width.
+      *
+      * @return widget width
+      */
+     public int getWidth() {
+         return this.width;
+     }
+     /**
+      * Change the width.
+      *
+      * @param width new widget width
+      */
+     public void setWidth(final int width) {
+         this.width = width;
+         if (layout != null) {
+             layout.onResize(new TResizeEvent(TResizeEvent.Type.WIDGET,
+                     width, height));
+         }
+     }
+     /**
+      * Get the height.
+      *
+      * @return widget height
+      */
+     public int getHeight() {
+         return this.height;
+     }
+     /**
+      * Change the height.
+      *
+      * @param height new widget height
+      */
+     public void setHeight(final int height) {
+         this.height = height;
+         if (layout != null) {
+             layout.onResize(new TResizeEvent(TResizeEvent.Type.WIDGET,
+                     width, height));
+         }
+     }
+     /**
+      * Change the dimensions.
+      *
+      * @param x absolute X position of the top-left corner
+      * @param y absolute Y position of the top-left corner
+      * @param width new widget width
+      * @param height new widget height
+      */
+     public final void setDimensions(final int x, final int y, final int width,
+         final int height) {
+         this.x = x;
+         this.y = y;
+         // Call the functions so that subclasses can choose how to handle it.
+         setWidth(width);
+         setHeight(height);
+         if (layout != null) {
+             layout.onResize(new TResizeEvent(TResizeEvent.Type.WIDGET,
+                     width, height));
+         }
+     }
+     /**
+      * Get the layout manager.
+      *
+      * @return the layout manager, or null if not set
+      */
+     public LayoutManager getLayoutManager() {
+         return layout;
+     }
+     /**
+      * Set the layout manager.
+      *
+      * @param layout the new layout manager
+      */
+     public void setLayoutManager(LayoutManager layout) {
+         if (this.layout != null) {
+             for (TWidget w: children) {
+                 this.layout.remove(w);
+             }
+             this.layout = null;
+         }
+         this.layout = layout;
+         if (this.layout != null) {
+             for (TWidget w: children) {
+                 this.layout.add(w);
+             }
+         }
+     }
+     /**
+      * Get enabled flag.
+      *
+      * @return if true, this widget can be tabbed to or receive events
+      */
+     public final boolean isEnabled() {
+         return enabled;
+     }
+     /**
+      * Set enabled flag.
+      *
+      * @param enabled if true, this widget can be tabbed to or receive events
+      */
+     public final void setEnabled(final boolean enabled) {
+         this.enabled = enabled;
+         if (!enabled) {
+             active = false;
+             // See if there are any active siblings to switch to
+             boolean foundSibling = false;
+             if (parent != null) {
+                 for (TWidget w: parent.children) {
+                     if ((w.enabled)
+                         && !(this instanceof THScroller)
+                         && !(this instanceof TVScroller)
+                     ) {
+                         parent.activate(w);
+                         foundSibling = true;
+                         break;
+                     }
+                 }
+                 if (!foundSibling) {
+                     parent.activeChild = null;
+                 }
+             }
+         }
+     }
+     /**
+      * Set visible flag.
+      *
+      * @param visible if true, this widget will be drawn
+      */
+     public final void setVisible(final boolean visible) {
+         this.visible = visible;
+     }
+     /**
+      * See if this widget is visible.
+      *
+      * @return if true, this widget will be drawn
+      */
+     public final boolean isVisible() {
+         return visible;
+     }
+     /**
+      * Set visible cursor flag.
+      *
+      * @param cursorVisible if true, this widget has a cursor
+      */
+     public final void setCursorVisible(final boolean cursorVisible) {
+         this.cursorVisible = cursorVisible;
+     }
+     /**
+      * See if this widget has a visible cursor.
+      *
+      * @return if true, this widget has a visible cursor
+      */
+     public final boolean isCursorVisible() {
+         // If cursor is out of my bounds, it is not visible.
+         if ((cursorX >= width)
+             || (cursorX < 0)
+             || (cursorY >= height)
+             || (cursorY < 0)
+         ) {
+             return false;
+         }
+         assert (window != null);
+         if (window instanceof TDesktop) {
+             // Desktop doesn't have a window border.
+             return cursorVisible;
+         }
+         // If cursor is out of my window's bounds, it is not visible.
+         if ((getCursorAbsoluteX() >= window.getAbsoluteX()
+                 + window.getWidth() - 1)
+             || (getCursorAbsoluteX() < 0)
+             || (getCursorAbsoluteY() >= window.getAbsoluteY()
+                 + window.getHeight() - 1)
+             || (getCursorAbsoluteY() < 0)
+         ) {
+             return false;
+         }
+         return cursorVisible;
+     }
+     /**
+      * Get cursor X value.
+      *
+      * @return cursor column position in relative coordinates
+      */
+     public final int getCursorX() {
+         return cursorX;
+     }
+     /**
+      * Set cursor X value.
+      *
+      * @param cursorX column position in relative coordinates
+      */
+     public final void setCursorX(final int cursorX) {
+         this.cursorX = cursorX;
+     }
+     /**
+      * Get cursor Y value.
+      *
+      * @return cursor row position in relative coordinates
+      */
+     public final int getCursorY() {
+         return cursorY;
+     }
+     /**
+      * Set cursor Y value.
+      *
+      * @param cursorY row position in relative coordinates
+      */
+     public final void setCursorY(final int cursorY) {
+         this.cursorY = cursorY;
+     }
+     /**
+      * Get this TWidget's parent TApplication.
+      *
+      * @return the parent TApplication, or null if not assigned
+      */
+     public TApplication getApplication() {
+         if (window != null) {
+             return window.getApplication();
+         }
+         return null;
+     }
+     /**
+      * Get the Screen.
+      *
+      * @return the Screen, or null if not assigned
+      */
+     public Screen getScreen() {
+         if (window != null) {
+             return window.getScreen();
+         }
+         return null;
+     }
+     /**
+      * Comparison operator.  For various subclasses it sorts on:
+      * <ul>
+      * <li>tabOrder for TWidgets</li>
+      * <li>z for TWindows</li>
+      * <li>text for TTreeItems</li>
+      * </ul>
+      *
+      * @param that another TWidget, TWindow, or TTreeItem instance
+      * @return difference between this.tabOrder and that.tabOrder, or
+      * difference between this.z and that.z, or String.compareTo(text)
+      */
+     @Override
+     public final int compareTo(final TWidget that) {
+         if ((this instanceof TWindow)
+             && (that instanceof TWindow)
+         ) {
+             return (((TWindow) this).getZ() - ((TWindow) that).getZ());
+         }
+         if ((this instanceof TTreeItem)
+             && (that instanceof TTreeItem)
+         ) {
+             return (((TTreeItem) this).getText().compareTo(
+                 ((TTreeItem) that).getText()));
+         }
+         return (this.tabOrder - that.tabOrder);
+     }
+     /**
+      * See if this widget should render with the active color.
+      *
+      * @return true if this widget is active and all of its parents are
+      * active.
+      */
+     public final boolean isAbsoluteActive() {
+         if (parent == this) {
+             return active;
+         }
+         return (active && (parent == null ? true : parent.isAbsoluteActive()));
+     }
+     /**
+      * Returns the cursor X position.
+      *
+      * @return absolute screen column number for the cursor's X position
+      */
+     public final int getCursorAbsoluteX() {
+         return getAbsoluteX() + cursorX;
+     }
+     /**
+      * Returns the cursor Y position.
+      *
+      * @return absolute screen row number for the cursor's Y position
+      */
+     public final int getCursorAbsoluteY() {
+         return getAbsoluteY() + cursorY;
+     }
+     /**
+      * Compute my absolute X position as the sum of my X plus all my parent's
+      * X's.
+      *
+      * @return absolute screen column number for my X position
+      */
+     public final int getAbsoluteX() {
+         assert (parent != null);
+         if (parent == this) {
+             return x;
+         }
+         if ((parent instanceof TWindow)
+             && !(parent instanceof TMenu)
+             && !(parent instanceof TDesktop)
+         ) {
+             // Widgets on a TWindow have (0,0) as their top-left, but this is
+             // actually the TWindow's (1,1).
+             return parent.getAbsoluteX() + x + 1;
+         }
+         return parent.getAbsoluteX() + x;
+     }
+     /**
+      * Compute my absolute Y position as the sum of my Y plus all my parent's
+      * Y's.
+      *
+      * @return absolute screen row number for my Y position
+      */
+     public final int getAbsoluteY() {
+         assert (parent != null);
+         if (parent == this) {
+             return y;
+         }
+         if ((parent instanceof TWindow)
+             && !(parent instanceof TMenu)
+             && !(parent instanceof TDesktop)
+         ) {
+             // Widgets on a TWindow have (0,0) as their top-left, but this is
+             // actually the TWindow's (1,1).
+             return parent.getAbsoluteY() + y + 1;
+         }
+         return parent.getAbsoluteY() + y;
+     }
+     /**
+      * Get the global color theme.
+      *
+      * @return the ColorTheme
+      */
+     protected final ColorTheme getTheme() {
+         return window.getApplication().getTheme();
+     }
+     /**
+      * See if this widget can be drawn onto a screen.
+      *
+      * @return true if this widget is part of the hierarchy that can draw to
+      * a screen
+      */
+     public final boolean isDrawable() {
+         if ((window == null)
+             || (window.getScreen() == null)
+             || (parent == null)
+         ) {
+             return false;
+         }
+         if (parent == this) {
+             return true;
+         }
+         return (parent.isDrawable());
+     }
+     /**
+      * Draw my specific widget.  When called, the screen rectangle I draw
+      * into is already setup (offset and clipping).
+      */
+     public void draw() {
+         // Default widget draws nothing.
+     }
+     /**
+      * Called by parent to render to TWindow.  Note package private access.
+      */
+     final void drawChildren() {
+         if (!isDrawable()) {
+             return;
+         }
+         // Set my clipping rectangle
+         assert (window != null);
+         assert (getScreen() != null);
+         Screen screen = getScreen();
+         // Special case: TStatusBar is drawn by TApplication, not anything
+         // else.
+         if (this instanceof TStatusBar) {
+             return;
+         }
+         screen.setClipRight(width);
+         screen.setClipBottom(height);
+         int absoluteRightEdge = window.getAbsoluteX() + window.getWidth();
+         int absoluteBottomEdge = window.getAbsoluteY() + window.getHeight();
+         if (!(this instanceof TWindow)
+             && !(this instanceof TVScroller)
+             && !(window instanceof TDesktop)
+         ) {
+             absoluteRightEdge -= 1;
+         }
+         if (!(this instanceof TWindow)
+             && !(this instanceof THScroller)
+             && !(window instanceof TDesktop)
+         ) {
+             absoluteBottomEdge -= 1;
+         }
+         int myRightEdge = getAbsoluteX() + width;
+         int myBottomEdge = getAbsoluteY() + height;
+         if (getAbsoluteX() > absoluteRightEdge) {
+             // I am offscreen
+             screen.setClipRight(0);
+         } else if (myRightEdge > absoluteRightEdge) {
+             screen.setClipRight(screen.getClipRight()
+                 - (myRightEdge - absoluteRightEdge));
+         }
+         if (getAbsoluteY() > absoluteBottomEdge) {
+             // I am offscreen
+             screen.setClipBottom(0);
+         } else if (myBottomEdge > absoluteBottomEdge) {
+             screen.setClipBottom(screen.getClipBottom()
+                 - (myBottomEdge - absoluteBottomEdge));
+         }
+         // Set my offset
+         screen.setOffsetX(getAbsoluteX());
+         screen.setOffsetY(getAbsoluteY());
+         // Draw me
+         draw();
+         if (!isDrawable()) {
+             // An action taken by a draw method unhooked me from the UI.
+             // Bail out.
+             return;
+         }
+         assert (visible == true);
+         // Continue down the chain.  Draw the active child last so that it
+         // is on top.
+         for (TWidget widget: children) {
+             if (widget.isVisible() && (widget != activeChild)) {
+                 widget.drawChildren();
+                 if (!isDrawable()) {
+                     // An action taken by a draw method unhooked me from the UI.
+                     // Bail out.
+                     return;
+                 }
+             }
+         }
+         if (activeChild != null) {
+             activeChild.drawChildren();
+         }
+     }
+     /**
+      * Repaint the screen on the next update.
+      */
+     protected final void doRepaint() {
+         window.getApplication().doRepaint();
+     }
+     /**
+      * Add a child widget to my list of children.  We set its tabOrder to 0
+      * and increment the tabOrder of all other children.
+      *
+      * @param child TWidget to add
+      */
+     private void addChild(final TWidget child) {
+         children.add(child);
+         if ((child.enabled)
+             && !(child instanceof THScroller)
+             && !(child instanceof TVScroller)
+         ) {
+             for (TWidget widget: children) {
+                 widget.active = false;
+             }
+             child.active = true;
+             activeChild = child;
+         }
+         for (int i = 0; i < children.size(); i++) {
+             children.get(i).tabOrder = i;
+         }
+         if (layout != null) {
+             layout.add(child);
+         }
+     }
+     /**
+      * Reset the tab order of children to match their position in the list.
+      * Available so that subclasses can re-order their widgets if needed.
+      */
+     protected void resetTabOrder() {
+         for (int i = 0; i < children.size(); i++) {
+             children.get(i).tabOrder = i;
+         }
+     }
+     
+     /**
+      * Remove and {@link TWidget#close()} the given child from this {@link TWidget}.
+      * <p>
+      * Will also reorder the tab values of the remaining children.
+      * 
+      * @param child the child to remove
+      * 
+      * @return TRUE if the child was removed, FALSE if it was not found
+      */
+     public boolean removeChild(final TWidget child) {
+         if (children.remove(child)) {
+                 child.close();
+                 child.parent = null;
+                 child.window = null;
+                 
+                 resetTabOrder();
+                 
+                 return true;
+         }
+         
+         return false;
+     }
+     /**
+      * Switch the active child.
+      *
+      * @param child TWidget to activate
+      */
+     public final void activate(final TWidget child) {
+         assert (child.enabled);
+         if ((child instanceof THScroller)
+             || (child instanceof TVScroller)
+         ) {
+             return;
+         }
+         if (children.size() == 1) {
+             if (children.get(0).enabled == true) {
+                 child.active = true;
+                 activeChild = child;
+             }
+         } else {
+             if (child != activeChild) {
+                 if (activeChild != null) {
+                     activeChild.active = false;
+                 }
+                 child.active = true;
+                 activeChild = child;
+             }
+         }
+     }
+     /**
+      * Switch the active child.
+      *
+      * @param tabOrder tabOrder of the child to activate.  If that child
+      * isn't enabled, then the next enabled child will be activated.
+      */
+     public final void activate(final int tabOrder) {
+         if (children.size() == 1) {
+             if (children.get(0).enabled == true) {
+                 children.get(0).active = true;
+                 activeChild = children.get(0);
+             }
+             return;
+         }
+         TWidget child = null;
+         for (TWidget widget: children) {
+             if ((widget.enabled)
+                 && !(widget instanceof THScroller)
+                 && !(widget instanceof TVScroller)
+                 && (widget.tabOrder >= tabOrder)
+             ) {
+                 child = widget;
+                 break;
+             }
+         }
+         if ((child != null) && (child != activeChild)) {
+             if (activeChild != null) {
+                 activeChild.active = false;
+             }
+             assert (child.enabled);
+             child.active = true;
+             activeChild = child;
+         }
+     }
+     /**
+      * Make this widget the active child of its parent.  Note that this is
+      * not final since TWindow overrides activate().
+      */
+     public void activate() {
+         if (enabled) {
+             if (parent != null) {
+                 parent.activate(this);
+             }
+         }
+     }
+     /**
+      * Make this widget, all of its parents, the active child.
+      */
+     public final void activateAll() {
+         activate();
+         if (parent == this) {
+             return;
+         }
+         if (parent != null) {
+             parent.activateAll();
+         }
+     }
+     /**
+      * Switch the active widget with the next in the tab order.
+      *
+      * @param forward if true, then switch to the next enabled widget in the
+      * list, otherwise switch to the previous enabled widget in the list
+      */
+     public final void switchWidget(final boolean forward) {
+         // No children: do nothing.
+         if (children.size() == 0) {
+             return;
+         }
+         assert (parent != null);
+         // If there is only one child, make it active if it is enabled.
+         if (children.size() == 1) {
+             if (children.get(0).enabled == true) {
+                 activeChild = children.get(0);
+                 activeChild.active = true;
+             } else {
+                 children.get(0).active = false;
+                 activeChild = null;
+             }
+             return;
+         }
+         // Two or more children: go forward or backward to the next enabled
+         // child.
+         int tabOrder = 0;
+         if (activeChild != null) {
+             tabOrder = activeChild.tabOrder;
+         }
+         do {
+             if (forward) {
+                 tabOrder++;
+             } else {
+                 tabOrder--;
+             }
+             if (tabOrder < 0) {
+                 // If at the end, pass the switch to my parent.
+                 if ((!forward) && (parent != this)) {
+                     parent.switchWidget(forward);
+                     return;
+                 }
+                 tabOrder = children.size() - 1;
+             } else if (tabOrder == children.size()) {
+                 // If at the end, pass the switch to my parent.
+                 if ((forward) && (parent != this)) {
+                     parent.switchWidget(forward);
+                     return;
+                 }
+                 tabOrder = 0;
+             }
+             if (activeChild == null) {
+                 if (tabOrder == 0) {
+                     // We wrapped around
+                     break;
+                 }
+             } else if (activeChild.tabOrder == tabOrder) {
+                 // We wrapped around
+                 break;
+             }
+         } while ((!children.get(tabOrder).enabled)
+             && !(children.get(tabOrder) instanceof THScroller)
+             && !(children.get(tabOrder) instanceof TVScroller));
+         if (activeChild != null) {
+             assert (children.get(tabOrder).enabled);
+             activeChild.active = false;
+         }
+         if (children.get(tabOrder).enabled == true) {
+             children.get(tabOrder).active = true;
+             activeChild = children.get(tabOrder);
+         }
+     }
+     /**
+      * Returns my active widget.
+      *
+      * @return widget that is active, or this if no children
+      */
+     public TWidget getActiveChild() {
+         if ((this instanceof THScroller)
+             || (this instanceof TVScroller)
+         ) {
+             return parent;
+         }
+         for (TWidget widget: children) {
+             if (widget.active) {
+                 return widget.getActiveChild();
+             }
+         }
+         // No active children, return me
+         return this;
+     }
+     /**
+      * Insert a vertical split between this widget and parent, and optionally
+      * put another widget in the other side of the split.
+      *
+      * @param newWidgetOnLeft if true, the new widget (if specified) will be
+      * on the left pane, and this widget will be placed on the right pane
+      * @param newWidget the new widget to add to the other pane, or null
+      * @return the new split pane widget
+      */
+     public TSplitPane splitVertical(final boolean newWidgetOnLeft,
+         final TWidget newWidget) {
+         TSplitPane splitPane = new TSplitPane(null, x, y, width, height, true);
+         TWidget myParent = parent;
+         remove(false);
+         if (myParent instanceof TSplitPane) {
+             // TSplitPane has a left/right/top/bottom link to me somewhere,
+             // replace it with a link to splitPane.
+             ((TSplitPane) myParent).replaceWidget(this, splitPane);
+         }
+         splitPane.setParent(myParent, false);
+         if (newWidgetOnLeft) {
+             splitPane.setLeft(newWidget);
+             splitPane.setRight(this);
+         } else {
+             splitPane.setLeft(this);
+             splitPane.setRight(newWidget);
+         }
+         if (newWidget != null) {
+             newWidget.activateAll();
+         } else {
+             activateAll();
+         }
+         assert (parent != null);
+         assert (window != null);
+         assert (splitPane.getWindow() != null);
+         assert (splitPane.getParent() != null);
+         assert (splitPane.isActive() == true);
+         assert (parent == splitPane);
+         if (newWidget != null) {
+             assert (newWidget.parent == parent);
+             assert (newWidget.active == true);
+             assert (active == false);
+         } else {
+             assert (active == true);
+         }
+         return splitPane;
+     }
+     /**
+      * Insert a horizontal split between this widget and parent, and
+      * optionally put another widget in the other side of the split.
+      *
+      * @param newWidgetOnTop if true, the new widget (if specified) will be
+      * on the top pane, and this widget's children will be placed on the
+      * bottom pane
+      * @param newWidget the new widget to add to the other pane, or null
+      * @return the new split pane widget
+      */
+     public TSplitPane splitHorizontal(final boolean newWidgetOnTop,
+         final TWidget newWidget) {
+         TSplitPane splitPane = new TSplitPane(null, x, y, width, height, false);
+         TWidget myParent = parent;
+         remove(false);
+         if (myParent instanceof TSplitPane) {
+             // TSplitPane has a left/right/top/bottom link to me somewhere,
+             // replace it with a link to splitPane.
+             ((TSplitPane) myParent).replaceWidget(this, splitPane);
+         }
+         splitPane.setParent(myParent, false);
+         if (newWidgetOnTop) {
+             splitPane.setTop(newWidget);
+             splitPane.setBottom(this);
+         } else {
+             splitPane.setTop(this);
+             splitPane.setBottom(newWidget);
+         }
+         if (newWidget != null) {
+             newWidget.activateAll();
+         } else {
+             activateAll();
+         }
+         assert (parent != null);
+         assert (window != null);
+         assert (splitPane.getWindow() != null);
+         assert (splitPane.getParent() != null);
+         assert (splitPane.isActive() == true);
+         assert (parent == splitPane);
+         if (newWidget != null) {
+             assert (newWidget.parent == parent);
+             assert (newWidget.active == true);
+             assert (active == false);
+         } else {
+             assert (active == true);
+         }
+         return splitPane;
+     }
+     /**
+      * Generate a human-readable string for this widget.
+      *
+      * @return a human-readable string
+      */
+     @Override
+     public String toString() {
+         return String.format("%s(%8x) position (%d, %d) geometry %dx%d " +
+             "active %s enabled %s visible %s", getClass().getName(),
+             hashCode(), x, y, width, height, active, enabled, visible);
+     }
+     /**
+      * Generate a string for this widget's hierarchy.
+      *
+      * @param prefix a prefix to use for this widget's place in the hierarchy
+      * @return a pretty-printable string of this hierarchy
+      */
+     protected String toPrettyString(final String prefix) {
+         StringBuilder sb = new StringBuilder(prefix);
+         sb.append(toString());
+         String newPrefix = "";
+         for (int i = 0; i < prefix.length(); i++) {
+             newPrefix += " ";
+         }
+         for (int i = 0; i < children.size(); i++) {
+             TWidget child= children.get(i);
+             sb.append("\n");
+             if (i == children.size() - 1) {
+                 sb.append(child.toPrettyString(newPrefix + " \u2514\u2500"));
+             } else {
+                 sb.append(child.toPrettyString(newPrefix + " \u251c\u2500"));
+             }
+         }
+         return sb.toString();
+     }
+     /**
+      * Generate a string for this widget's hierarchy.
+      *
+      * @return a pretty-printable string of this hierarchy
+      */
+     public String toPrettyString() {
+         return toPrettyString("");
+     }
+     // ------------------------------------------------------------------------
+     // Passthru for Screen functions ------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Get the attributes at one location.
+      *
+      * @param x column coordinate.  0 is the left-most column.
+      * @param y row coordinate.  0 is the top-most row.
+      * @return attributes at (x, y)
+      */
+     protected final CellAttributes getAttrXY(final int x, final int y) {
+         return getScreen().getAttrXY(x, y);
+     }
+     /**
+      * Set the attributes at one location.
+      *
+      * @param x column coordinate.  0 is the left-most column.
+      * @param y row coordinate.  0 is the top-most row.
+      * @param attr attributes to use (bold, foreColor, backColor)
+      */
+     protected final void putAttrXY(final int x, final int y,
+         final CellAttributes attr) {
+         getScreen().putAttrXY(x, y, attr);
+     }
+     /**
+      * Set the attributes at one location.
+      *
+      * @param x column coordinate.  0 is the left-most column.
+      * @param y row coordinate.  0 is the top-most row.
+      * @param attr attributes to use (bold, foreColor, backColor)
+      * @param clip if true, honor clipping/offset
+      */
+     protected final void putAttrXY(final int x, final int y,
+         final CellAttributes attr, final boolean clip) {
+         getScreen().putAttrXY(x, y, attr, clip);
+     }
+     /**
+      * Fill the entire screen with one character with attributes.
+      *
+      * @param ch character to draw
+      * @param attr attributes to use (bold, foreColor, backColor)
+      */
+     protected final void putAll(final int ch, final CellAttributes attr) {
+         getScreen().putAll(ch, attr);
+     }
+     /**
+      * Render one character with attributes.
+      *
+      * @param x column coordinate.  0 is the left-most column.
+      * @param y row coordinate.  0 is the top-most row.
+      * @param ch character + attributes to draw
+      */
+     protected final void putCharXY(final int x, final int y, final Cell ch) {
+         getScreen().putCharXY(x, y, ch);
+     }
+     /**
+      * Render one character with attributes.
+      *
+      * @param x column coordinate.  0 is the left-most column.
+      * @param y row coordinate.  0 is the top-most row.
+      * @param ch character to draw
+      * @param attr attributes to use (bold, foreColor, backColor)
+      */
+     protected final void putCharXY(final int x, final int y, final int ch,
+         final CellAttributes attr) {
+         getScreen().putCharXY(x, y, ch, attr);
+     }
+     /**
+      * Render one character without changing the underlying attributes.
+      *
+      * @param x column coordinate.  0 is the left-most column.
+      * @param y row coordinate.  0 is the top-most row.
+      * @param ch character to draw
+      */
+     protected final void putCharXY(final int x, final int y, final int ch) {
+         getScreen().putCharXY(x, y, ch);
+     }
+     /**
+      * Render a string.  Does not wrap if the string exceeds the line.
+      *
+      * @param x column coordinate.  0 is the left-most column.
+      * @param y row coordinate.  0 is the top-most row.
+      * @param str string to draw
+      * @param attr attributes to use (bold, foreColor, backColor)
+      */
+     protected final void putStringXY(final int x, final int y, final String str,
+         final CellAttributes attr) {
+         getScreen().putStringXY(x, y, str, attr);
+     }
+     /**
+      * Render a string without changing the underlying attribute.  Does not
+      * wrap if the string exceeds the line.
+      *
+      * @param x column coordinate.  0 is the left-most column.
+      * @param y row coordinate.  0 is the top-most row.
+      * @param str string to draw
+      */
+     protected final void putStringXY(final int x, final int y, final String str) {
+         getScreen().putStringXY(x, y, str);
+     }
+     /**
+      * Draw a vertical line from (x, y) to (x, y + n).
+      *
+      * @param x column coordinate.  0 is the left-most column.
+      * @param y row coordinate.  0 is the top-most row.
+      * @param n number of characters to draw
+      * @param ch character to draw
+      * @param attr attributes to use (bold, foreColor, backColor)
+      */
+     protected final void vLineXY(final int x, final int y, final int n,
+         final int ch, final CellAttributes attr) {
+         getScreen().vLineXY(x, y, n, ch, attr);
+     }
+     /**
+      * Draw a horizontal line from (x, y) to (x + n, y).
+      *
+      * @param x column coordinate.  0 is the left-most column.
+      * @param y row coordinate.  0 is the top-most row.
+      * @param n number of characters to draw
+      * @param ch character to draw
+      * @param attr attributes to use (bold, foreColor, backColor)
+      */
+     protected final void hLineXY(final int x, final int y, final int n,
+         final int ch, final CellAttributes attr) {
+         getScreen().hLineXY(x, y, n, ch, attr);
+     }
+     /**
+      * Draw a box with a border and empty background.
+      *
+      * @param left left column of box.  0 is the left-most row.
+      * @param top top row of the box.  0 is the top-most row.
+      * @param right right column of box
+      * @param bottom bottom row of the box
+      * @param border attributes to use for the border
+      * @param background attributes to use for the background
+      */
+     protected final void drawBox(final int left, final int top,
+         final int right, final int bottom,
+         final CellAttributes border, final CellAttributes background) {
+         getScreen().drawBox(left, top, right, bottom, border, background);
+     }
+     /**
+      * Draw a box with a border and empty background.
+      *
+      * @param left left column of box.  0 is the left-most row.
+      * @param top top row of the box.  0 is the top-most row.
+      * @param right right column of box
+      * @param bottom bottom row of the box
+      * @param border attributes to use for the border
+      * @param background attributes to use for the background
+      * @param borderType if 1, draw a single-line border; if 2, draw a
+      * double-line border; if 3, draw double-line top/bottom edges and
+      * single-line left/right edges (like Qmodem)
+      * @param shadow if true, draw a "shadow" on the box
+      */
+     protected final void drawBox(final int left, final int top,
+         final int right, final int bottom,
+         final CellAttributes border, final CellAttributes background,
+         final int borderType, final boolean shadow) {
+         getScreen().drawBox(left, top, right, bottom, border, background,
+             borderType, shadow);
+     }
+     /**
+      * Draw a box shadow.
+      *
+      * @param left left column of box.  0 is the left-most row.
+      * @param top top row of the box.  0 is the top-most row.
+      * @param right right column of box
+      * @param bottom bottom row of the box
+      */
+     protected final void drawBoxShadow(final int left, final int top,
+         final int right, final int bottom) {
+         getScreen().drawBoxShadow(left, top, right, bottom);
+     }
+     // ------------------------------------------------------------------------
+     // Other TWidget constructors ---------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Convenience function to add a label to this container/window.
+      *
+      * @param text label
+      * @param x column relative to parent
+      * @param y row relative to parent
+      * @return the new label
+      */
+     public final TLabel addLabel(final String text, final int x, final int y) {
+         return addLabel(text, x, y, "tlabel");
+     }
+     /**
+      * Convenience function to add a label to this container/window.
+      *
+      * @param text label
+      * @param x column relative to parent
+      * @param y row relative to parent
+      * @param action to call when shortcut is pressed
+      * @return the new label
+      */
+     public final TLabel addLabel(final String text, final int x, final int y,
+         final TAction action) {
+         return addLabel(text, x, y, "tlabel", action);
+     }
+     /**
+      * Convenience function to add a label to this container/window.
+      *
+      * @param text label
+      * @param x column relative to parent
+      * @param y row relative to parent
+      * @param colorKey ColorTheme key color to use for foreground text.
+      * Default is "tlabel"
+      * @return the new label
+      */
+     public final TLabel addLabel(final String text, final int x, final int y,
+         final String colorKey) {
+         return new TLabel(this, text, x, y, colorKey);
+     }
+     /**
+      * Convenience function to add a label to this container/window.
+      *
+      * @param text label
+      * @param x column relative to parent
+      * @param y row relative to parent
+      * @param colorKey ColorTheme key color to use for foreground text.
+      * Default is "tlabel"
+      * @param action to call when shortcut is pressed
+      * @return the new label
+      */
+     public final TLabel addLabel(final String text, final int x, final int y,
+         final String colorKey, final TAction action) {
+         return new TLabel(this, text, x, y, colorKey, action);
+     }
+     /**
+      * Convenience function to add a label to this container/window.
+      *
+      * @param text label
+      * @param x column relative to parent
+      * @param y row relative to parent
+      * @param colorKey ColorTheme key color to use for foreground text.
+      * Default is "tlabel"
+      * @param useWindowBackground if true, use the window's background color
+      * @return the new label
+      */
+     public final TLabel addLabel(final String text, final int x, final int y,
+         final String colorKey, final boolean useWindowBackground) {
+         return new TLabel(this, text, x, y, colorKey, useWindowBackground);
+     }
+     /**
+      * Convenience function to add a label to this container/window.
+      *
+      * @param text label
+      * @param x column relative to parent
+      * @param y row relative to parent
+      * @param colorKey ColorTheme key color to use for foreground text.
+      * Default is "tlabel"
+      * @param useWindowBackground if true, use the window's background color
+      * @param action to call when shortcut is pressed
+      * @return the new label
+      */
+     public final TLabel addLabel(final String text, final int x, final int y,
+         final String colorKey, final boolean useWindowBackground,
+         final TAction action) {
+         return new TLabel(this, text, x, y, colorKey, useWindowBackground,
+             action);
+     }
+     /**
+      * Convenience function to add a button to this container/window.
+      *
+      * @param text label on the button
+      * @param x column relative to parent
+      * @param y row relative to parent
+      * @param action action to call when button is pressed
+      * @return the new button
+      */
+     public final TButton addButton(final String text, final int x, final int y,
+         final TAction action) {
+         return new TButton(this, text, x, y, action);
+     }
+     /**
+      * Convenience function to add a checkbox to this container/window.
+      *
+      * @param x column relative to parent
+      * @param y row relative to parent
+      * @param label label to display next to (right of) the checkbox
+      * @param checked initial check state
+      * @return the new checkbox
+      */
+     public final TCheckBox addCheckBox(final int x, final int y,
+         final String label, final boolean checked) {
+         return new TCheckBox(this, x, y, label, checked);
+     }
+     /**
+      * Convenience function to add a combobox to this container/window.
+      *
+      * @param x column relative to parent
+      * @param y row relative to parent
+      * @param width visible combobox width, including the down-arrow
+      * @param values the possible values for the box, shown in the drop-down
+      * @param valuesIndex the initial index in values, or -1 for no default
+      * value
+      * @param maxValuesHeight the maximum height of the values drop-down when
+      * it is visible
+      * @param updateAction action to call when a new value is selected from
+      * the list or enter is pressed in the edit field
+      * @return the new combobox
+      */
+     public final TComboBox addComboBox(final int x, final int y,
+         final int width, final List<String> values, final int valuesIndex,
+         final int maxValuesHeight, final TAction updateAction) {
+         return new TComboBox(this, x, y, width, values, valuesIndex,
+             maxValuesHeight, updateAction);
+     }
+     /**
+      * Convenience function to add a spinner to this container/window.
+      *
+      * @param x column relative to parent
+      * @param y row relative to parent
+      * @param upAction action to call when the up arrow is clicked or pressed
+      * @param downAction action to call when the down arrow is clicked or
+      * pressed
+      * @return the new spinner
+      */
+     public final TSpinner addSpinner(final int x, final int y,
+         final TAction upAction, final TAction downAction) {
+         return new TSpinner(this, x, y, upAction, downAction);
+     }
+     /**
+      * Convenience function to add a calendar to this container/window.
+      *
+      * @param x column relative to parent
+      * @param y row relative to parent
+      * @param updateAction action to call when the user changes the value of
+      * the calendar
+      * @return the new calendar
+      */
+     public final TCalendar addCalendar(final int x, final int y,
+         final TAction updateAction) {
+         return new TCalendar(this, x, y, updateAction);
+     }
+     /**
+      * Convenience function to add a progress bar to this container/window.
+      *
+      * @param x column relative to parent
+      * @param y row relative to parent
+      * @param width width of progress bar
+      * @param value initial value of percent complete
+      * @return the new progress bar
+      */
+     public final TProgressBar addProgressBar(final int x, final int y,
+         final int width, final int value) {
+         return new TProgressBar(this, x, y, width, value);
+     }
+     /**
+      * Convenience function to add a radio button group to this
+      * container/window.
+      *
+      * @param x column relative to parent
+      * @param y row relative to parent
+      * @param label label to display on the group box
+      * @return the new radio button group
+      */
+     public final TRadioGroup addRadioGroup(final int x, final int y,
+         final String label) {
+         return new TRadioGroup(this, x, y, label);
+     }
+     /**
+      * Convenience function to add a text field to this container/window.
+      *
+      * @param x column relative to parent
+      * @param y row relative to parent
+      * @param width visible text width
+      * @param fixed if true, the text cannot exceed the display width
+      * @return the new text field
+      */
+     public final TField addField(final int x, final int y,
+         final int width, final boolean fixed) {
+         return new TField(this, x, y, width, fixed);
+     }
+     /**
+      * Convenience function to add a text field to this container/window.
+      *
+      * @param x column relative to parent
+      * @param y row relative to parent
+      * @param width visible text width
+      * @param fixed if true, the text cannot exceed the display width
+      * @param text initial text, default is empty string
+      * @return the new text field
+      */
+     public final TField addField(final int x, final int y,
+         final int width, final boolean fixed, final String text) {
+         return new TField(this, x, y, width, fixed, text);
+     }
+     /**
+      * Convenience function to add a text field to this container/window.
+      *
+      * @param x column relative to parent
+      * @param y row relative to parent
+      * @param width visible text width
+      * @param fixed if true, the text cannot exceed the display width
+      * @param text initial text, default is empty string
+      * @param enterAction function to call when enter key is pressed
+      * @param updateAction function to call when the text is updated
+      * @return the new text field
+      */
+     public final TField addField(final int x, final int y,
+         final int width, final boolean fixed, final String text,
+         final TAction enterAction, final TAction updateAction) {
+         return new TField(this, x, y, width, fixed, text, enterAction,
+             updateAction);
+     }
+     /**
+      * Convenience function to add a scrollable text box to this
+      * container/window.
+      *
+      * @param text text on the screen
+      * @param x column relative to parent
+      * @param y row relative to parent
+      * @param width width of text area
+      * @param height height of text area
+      * @param colorKey ColorTheme key color to use for foreground text
+      * @return the new text box
+      */
+     public final TText addText(final String text, final int x,
+         final int y, final int width, final int height, final String colorKey) {
+         return new TText(this, text, x, y, width, height, colorKey);
+     }
+     /**
+      * Convenience function to add a scrollable text box to this
+      * container/window.
+      *
+      * @param text text on the screen
+      * @param x column relative to parent
+      * @param y row relative to parent
+      * @param width width of text area
+      * @param height height of text area
+      * @return the new text box
+      */
+     public final TText addText(final String text, final int x, final int y,
+         final int width, final int height) {
+         return new TText(this, text, x, y, width, height, "ttext");
+     }
+     /**
+      * Convenience function to add an editable text area box to this
+      * container/window.
+      *
+      * @param text text on the screen
+      * @param x column relative to parent
+      * @param y row relative to parent
+      * @param width width of text area
+      * @param height height of text area
+      * @return the new text box
+      */
+     public final TEditorWidget addEditor(final String text, final int x,
+         final int y, final int width, final int height) {
+         return new TEditorWidget(this, text, x, y, width, height);
+     }
+     /**
+      * Convenience function to spawn a message box.
+      *
+      * @param title window title, will be centered along the top border
+      * @param caption message to display.  Use embedded newlines to get a
+      * multi-line box.
+      * @return the new message box
+      */
+     public final TMessageBox messageBox(final String title,
+         final String caption) {
+         return getApplication().messageBox(title, caption, TMessageBox.Type.OK);
+     }
+     /**
+      * Convenience function to spawn a message box.
+      *
+      * @param title window title, will be centered along the top border
+      * @param caption message to display.  Use embedded newlines to get a
+      * multi-line box.
+      * @param type one of the TMessageBox.Type constants.  Default is
+      * Type.OK.
+      * @return the new message box
+      */
+     public final TMessageBox messageBox(final String title,
+         final String caption, final TMessageBox.Type type) {
+         return getApplication().messageBox(title, caption, type);
+     }
+     /**
+      * Convenience function to spawn an input box.
+      *
+      * @param title window title, will be centered along the top border
+      * @param caption message to display.  Use embedded newlines to get a
+      * multi-line box.
+      * @return the new input box
+      */
+     public final TInputBox inputBox(final String title, final String caption) {
+         return getApplication().inputBox(title, caption);
+     }
+     /**
+      * Convenience function to spawn an input box.
+      *
+      * @param title window title, will be centered along the top border
+      * @param caption message to display.  Use embedded newlines to get a
+      * multi-line box.
+      * @param text initial text to seed the field with
+      * @return the new input box
+      */
+     public final TInputBox inputBox(final String title, final String caption,
+         final String text) {
+         return getApplication().inputBox(title, caption, text);
+     }
+     /**
+      * Convenience function to spawn an input box.
+      *
+      * @param title window title, will be centered along the top border
+      * @param caption message to display.  Use embedded newlines to get a
+      * multi-line box.
+      * @param text initial text to seed the field with
+      * @param type one of the Type constants.  Default is Type.OK.
+      * @return the new input box
+      */
+     public final TInputBox inputBox(final String title, final String caption,
+         final String text, final TInputBox.Type type) {
+         return getApplication().inputBox(title, caption, text, type);
+     }
+     /**
+      * Convenience function to add a password text field to this
+      * container/window.
+      *
+      * @param x column relative to parent
+      * @param y row relative to parent
+      * @param width visible text width
+      * @param fixed if true, the text cannot exceed the display width
+      * @return the new text field
+      */
+     public final TPasswordField addPasswordField(final int x, final int y,
+         final int width, final boolean fixed) {
+         return new TPasswordField(this, x, y, width, fixed);
+     }
+     /**
+      * Convenience function to add a password text field to this
+      * container/window.
+      *
+      * @param x column relative to parent
+      * @param y row relative to parent
+      * @param width visible text width
+      * @param fixed if true, the text cannot exceed the display width
+      * @param text initial text, default is empty string
+      * @return the new text field
+      */
+     public final TPasswordField addPasswordField(final int x, final int y,
+         final int width, final boolean fixed, final String text) {
+         return new TPasswordField(this, x, y, width, fixed, text);
+     }
+     /**
+      * Convenience function to add a password text field to this
+      * container/window.
+      *
+      * @param x column relative to parent
+      * @param y row relative to parent
+      * @param width visible text width
+      * @param fixed if true, the text cannot exceed the display width
+      * @param text initial text, default is empty string
+      * @param enterAction function to call when enter key is pressed
+      * @param updateAction function to call when the text is updated
+      * @return the new text field
+      */
+     public final TPasswordField addPasswordField(final int x, final int y,
+         final int width, final boolean fixed, final String text,
+         final TAction enterAction, final TAction updateAction) {
+         return new TPasswordField(this, x, y, width, fixed, text, enterAction,
+             updateAction);
+     }
+     /**
+      * Convenience function to add a scrollable tree view to this
+      * container/window.
+      *
+      * @param x column relative to parent
+      * @param y row relative to parent
+      * @param width width of tree view
+      * @param height height of tree view
+      * @return the new tree view
+      */
+     public final TTreeViewWidget addTreeViewWidget(final int x, final int y,
+         final int width, final int height) {
+         return new TTreeViewWidget(this, x, y, width, height);
+     }
+     /**
+      * Convenience function to add a scrollable tree view to this
+      * container/window.
+      *
+      * @param x column relative to parent
+      * @param y row relative to parent
+      * @param width width of tree view
+      * @param height height of tree view
+      * @param action action to perform when an item is selected
+      * @return the new tree view
+      */
+     public final TTreeViewWidget addTreeViewWidget(final int x, final int y,
+         final int width, final int height, final TAction action) {
+         return new TTreeViewWidget(this, x, y, width, height, action);
+     }
+     /**
+      * Convenience function to spawn a file open box.
+      *
+      * @param path path of selected file
+      * @return the result of the new file open box
+      * @throws IOException if a java.io operation throws
+      */
+     public final String fileOpenBox(final String path) throws IOException {
+         return getApplication().fileOpenBox(path);
+     }
+     /**
+      * Convenience function to spawn a file save box.
+      *
+      * @param path path of selected file
+      * @return the result of the new file open box
+      * @throws IOException if a java.io operation throws
+      */
+     public final String fileSaveBox(final String path) throws IOException {
+         return getApplication().fileOpenBox(path, TFileOpenBox.Type.SAVE);
+     }
+     /**
+      * Convenience function to spawn a file open box.
+      *
+      * @param path path of selected file
+      * @param type one of the Type constants
+      * @return the result of the new file open box
+      * @throws IOException if a java.io operation throws
+      */
+     public final String fileOpenBox(final String path,
+         final TFileOpenBox.Type type) throws IOException {
+         return getApplication().fileOpenBox(path, type);
+     }
+     /**
+      * Convenience function to spawn a file open box.
+      *
+      * @param path path of selected file
+      * @param type one of the Type constants
+      * @param filter a string that files must match to be displayed
+      * @return the result of the new file open box
+      * @throws IOException of a java.io operation throws
+      */
+     public final String fileOpenBox(final String path,
+         final TFileOpenBox.Type type, final String filter) throws IOException {
+         ArrayList<String> filters = new ArrayList<String>();
+         filters.add(filter);
+         return getApplication().fileOpenBox(path, type, filters);
+     }
+     /**
+      * Convenience function to spawn a file open box.
+      *
+      * @param path path of selected file
+      * @param type one of the Type constants
+      * @param filters a list of strings that files must match to be displayed
+      * @return the result of the new file open box
+      * @throws IOException of a java.io operation throws
+      */
+     public final String fileOpenBox(final String path,
+         final TFileOpenBox.Type type,
+         final List<String> filters) throws IOException {
+         return getApplication().fileOpenBox(path, type, filters);
+     }
+     /**
+      * Convenience function to add a directory list to this container/window.
+      *
+      * @param path directory path, must be a directory
+      * @param x column relative to parent
+      * @param y row relative to parent
+      * @param width width of text area
+      * @param height height of text area
+      * @return the new directory list
+      */
+     public final TDirectoryList addDirectoryList(final String path, final int x,
+         final int y, final int width, final int height) {
+         return new TDirectoryList(this, path, x, y, width, height, null);
+     }
+     /**
+      * Convenience function to add a directory list to this container/window.
+      *
+      * @param path directory path, must be a directory
+      * @param x column relative to parent
+      * @param y row relative to parent
+      * @param width width of text area
+      * @param height height of text area
+      * @param action action to perform when an item is selected (enter or
+      * double-click)
+      * @return the new directory list
+      */
+     public final TDirectoryList addDirectoryList(final String path, final int x,
+         final int y, final int width, final int height, final TAction action) {
+         return new TDirectoryList(this, path, x, y, width, height, action);
+     }
+     /**
+      * Convenience function to add a directory list to this container/window.
+      *
+      * @param path directory path, must be a directory
+      * @param x column relative to parent
+      * @param y row relative to parent
+      * @param width width of text area
+      * @param height height of text area
+      * @param action action to perform when an item is selected (enter or
+      * double-click)
+      * @param singleClickAction action to perform when an item is selected
+      * (single-click)
+      * @return the new directory list
+      */
+     public final TDirectoryList addDirectoryList(final String path, final int x,
+         final int y, final int width, final int height, final TAction action,
+         final TAction singleClickAction) {
+         return new TDirectoryList(this, path, x, y, width, height, action,
+             singleClickAction);
+     }
+     /**
+      * Convenience function to add a directory list to this container/window.
+      *
+      * @param path directory path, must be a directory
+      * @param x column relative to parent
+      * @param y row relative to parent
+      * @param width width of text area
+      * @param height height of text area
+      * @param action action to perform when an item is selected (enter or
+      * double-click)
+      * @param singleClickAction action to perform when an item is selected
+      * (single-click)
+      * @param filters a list of strings that files must match to be displayed
+      * @return the new directory list
+      */
+     public final TDirectoryList addDirectoryList(final String path, final int x,
+         final int y, final int width, final int height, final TAction action,
+         final TAction singleClickAction, final List<String> filters) {
+         return new TDirectoryList(this, path, x, y, width, height, action,
+             singleClickAction, filters);
+     }
+     /**
+      * Convenience function to add a list to this container/window.
+      *
+      * @param strings list of strings to show
+      * @param x column relative to parent
+      * @param y row relative to parent
+      * @param width width of text area
+      * @param height height of text area
+      * @return the new directory list
+      */
+     public final TList addList(final List<String> strings, final int x,
+         final int y, final int width, final int height) {
+         return new TList(this, strings, x, y, width, height, null);
+     }
+     /**
+      * Convenience function to add a list to this container/window.
+      *
+      * @param strings list of strings to show
+      * @param x column relative to parent
+      * @param y row relative to parent
+      * @param width width of text area
+      * @param height height of text area
+      * @param enterAction action to perform when an item is selected
+      * @return the new directory list
+      */
+     public final TList addList(final List<String> strings, final int x,
+         final int y, final int width, final int height,
+         final TAction enterAction) {
+         return new TList(this, strings, x, y, width, height, enterAction);
+     }
+     /**
+      * Convenience function to add a list to this container/window.
+      *
+      * @param strings list of strings to show
+      * @param x column relative to parent
+      * @param y row relative to parent
+      * @param width width of text area
+      * @param height height of text area
+      * @param enterAction action to perform when an item is selected
+      * @param moveAction action to perform when the user navigates to a new
+      * item with arrow/page keys
+      * @return the new directory list
+      */
+     public final TList addList(final List<String> strings, final int x,
+         final int y, final int width, final int height,
+         final TAction enterAction, final TAction moveAction) {
+         return new TList(this, strings, x, y, width, height, enterAction,
+             moveAction);
+     }
+     /**
+      * Convenience function to add a list to this container/window.
+      *
+      * @param strings list of strings to show.  This is allowed to be null
+      * and set later with setList() or by subclasses.
+      * @param x column relative to parent
+      * @param y row relative to parent
+      * @param width width of text area
+      * @param height height of text area
+      * @param enterAction action to perform when an item is selected
+      * @param moveAction action to perform when the user navigates to a new
+      * item with arrow/page keys
+      * @param singleClickAction action to perform when the user clicks on an
+      * item
+      */
+     public TList addList(final List<String> strings, final int x,
+         final int y, final int width, final int height,
+         final TAction enterAction, final TAction moveAction,
+         final TAction singleClickAction) {
+         return new TList(this, strings, x, y, width, height, enterAction,
+             moveAction, singleClickAction);
+     }
+     /**
+      * Convenience function to add an image to this container/window.
+      *
+      * @param x column relative to parent
+      * @param y row relative to parent
+      * @param width number of text cells for width of the image
+      * @param height number of text cells for height of the image
+      * @param image the image to display
+      * @param left left column of the image.  0 is the left-most column.
+      * @param top top row of the image.  0 is the top-most row.
+      */
+     public final TImage addImage(final int x, final int y,
+         final int width, final int height,
+         final BufferedImage image, final int left, final int top) {
+         return new TImage(this, x, y, width, height, image, left, top);
+     }
+     /**
+      * Convenience function to add an image to this container/window.
+      *
+      * @param x column relative to parent
+      * @param y row relative to parent
+      * @param width number of text cells for width of the image
+      * @param height number of text cells for height of the image
+      * @param image the image to display
+      * @param left left column of the image.  0 is the left-most column.
+      * @param top top row of the image.  0 is the top-most row.
+      * @param clickAction function to call when mouse is pressed
+      */
+     public final TImage addImage(final int x, final int y,
+         final int width, final int height,
+         final BufferedImage image, final int left, final int top,
+         final TAction clickAction) {
+         return new TImage(this, x, y, width, height, image, left, top,
+             clickAction);
+     }
+     /**
+      * Convenience function to add an editable 2D data table to this
+      * container/window.
+      *
+      * @param x column relative to parent
+      * @param y row relative to parent
+      * @param width width of widget
+      * @param height height of widget
+      */
+     public TTableWidget addTable(final int x, final int y, final int width,
+         final int height) {
+         return new TTableWidget(this, x, y, width, height);
+     }
+     /**
+      * Convenience function to add an editable 2D data table to this
+      * container/window.
+      *
+      * @param x column relative to parent
+      * @param y row relative to parent
+      * @param width width of widget
+      * @param height height of widget
+      * @param gridColumns number of columns in grid
+      * @param gridRows number of rows in grid
+      */
+     public TTableWidget addTable(final int x, final int y, final int width,
+         final int height, final int gridColumns, final int gridRows) {
+         return new TTableWidget(this, x, y, width, height, gridColumns,
+             gridRows);
+     }
+     /**
+      * Convenience function to add a panel to this container/window.
+      *
+      * @param x column relative to parent
+      * @param y row relative to parent
+      * @param width width of text area
+      * @param height height of text area
+      * @return the new panel
+      */
+     public final TPanel addPanel(final int x, final int y, final int width,
+         final int height) {
+         return new TPanel(this, x, y, width, height);
+     }
+     /**
+      * Convenience function to add a split pane to this container/window.
+      *
+      * @param x column relative to parent
+      * @param y row relative to parent
+      * @param width width of text area
+      * @param height height of text area
+      * @param vertical if true, split vertically
+      * @return the new split pane
+      */
+     public final TSplitPane addSplitPane(final int x, final int y,
+         final int width, final int height, final boolean vertical) {
+         return new TSplitPane(this, x, y, width, height, vertical);
+     }
+ }
diff --combined src/jexer/TWindow.java
index 0000000000000000000000000000000000000000,58195c915f1ae885485891f159539f53ece7b474..58195c915f1ae885485891f159539f53ece7b474
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,1455 +1,1455 @@@
+ /*
+  * Jexer - Java Text User Interface
+  *
+  * The MIT License (MIT)
+  *
+  * 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"),
+  * to deal in the Software without restriction, including without limitation
+  * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+  * and/or sell copies of the Software, and to permit persons to whom the
+  * Software is furnished to do so, subject to the following conditions:
+  *
+  * The above copyright notice and this permission notice shall be included in
+  * all copies or substantial portions of the Software.
+  *
+  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+  * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+  * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+  * DEALINGS IN THE SOFTWARE.
+  *
+  * @author Kevin Lamonte [kevin.lamonte@gmail.com]
+  * @version 1
+  */
+ package jexer;
+ import java.util.HashSet;
+ import java.util.Set;
+ import jexer.backend.Screen;
+ import jexer.bits.CellAttributes;
+ import jexer.bits.GraphicsChars;
+ import jexer.bits.StringUtils;
+ import jexer.event.TCommandEvent;
+ import jexer.event.TKeypressEvent;
+ import jexer.event.TMenuEvent;
+ import jexer.event.TMouseEvent;
+ import jexer.event.TResizeEvent;
+ import jexer.menu.TMenu;
+ import static jexer.TCommand.*;
+ import static jexer.TKeypress.*;
+ /**
+  * TWindow is the top-level container and drawing surface for other widgets.
+  */
+ public class TWindow extends TWidget {
+     // ------------------------------------------------------------------------
+     // Constants --------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Window is resizable (default yes).
+      */
+     public static final int RESIZABLE   = 0x01;
+     /**
+      * Window is modal (default no).
+      */
+     public static final int MODAL       = 0x02;
+     /**
+      * Window is centered (default no).
+      */
+     public static final int CENTERED    = 0x04;
+     /**
+      * Window has no close box (default no).  Window can still be closed via
+      * TApplication.closeWindow() and TWindow.close().
+      */
+     public static final int NOCLOSEBOX  = 0x08;
+     /**
+      * Window has no maximize box (default no).
+      */
+     public static final int NOZOOMBOX   = 0x10;
+     /**
+      * Window is placed at absolute position (no smart placement) (default
+      * no).
+      */
+     public static final int ABSOLUTEXY  = 0x20;
+     /**
+      * Hitting the closebox with the mouse calls TApplication.hideWindow()
+      * rather than TApplication.closeWindow() (default no).
+      */
+     public static final int HIDEONCLOSE = 0x40;
+     /**
+      * Menus cannot be used when this window is active (default no).
+      */
+     public static final int OVERRIDEMENU        = 0x80;
+     // ------------------------------------------------------------------------
+     // Variables --------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Window flags.  Note package private access.
+      */
+     int flags = RESIZABLE;
+     /**
+      * Window title.
+      */
+     private String title = "";
+     /**
+      * Window's parent TApplication.
+      */
+     private TApplication application;
+     /**
+      * Z order.  Lower number means more in-front.
+      */
+     private int z = 0;
+     /**
+      * Window's keyboard shortcuts.  Any key in this set will be passed to
+      * the window directly rather than processed through the menu
+      * accelerators.
+      */
+     private Set<TKeypress> keyboardShortcuts = new HashSet<TKeypress>();
+     /**
+      * If true, then the user clicked on the title bar and is moving the
+      * window.
+      */
+     protected boolean inWindowMove = false;
+     /**
+      * If true, then the user clicked on the bottom right corner and is
+      * resizing the window.
+      */
+     protected boolean inWindowResize = false;
+     /**
+      * If true, then the user selected "Size/Move" (or hit Ctrl-F5) and is
+      * resizing/moving the window via the keyboard.
+      */
+     protected boolean inKeyboardResize = false;
+     /**
+      * If true, this window is maximized.
+      */
+     private boolean maximized = false;
+     /**
+      * Remember mouse state.
+      */
+     protected TMouseEvent mouse;
+     // For moving the window.  resizing also uses moveWindowMouseX/Y
+     private int moveWindowMouseX;
+     private int moveWindowMouseY;
+     private int oldWindowX;
+     private int oldWindowY;
+     // Resizing
+     private int resizeWindowWidth;
+     private int resizeWindowHeight;
+     private int minimumWindowWidth = 10;
+     private int minimumWindowHeight = 2;
+     private int maximumWindowWidth = -1;
+     private int maximumWindowHeight = -1;
+     // For maximize/restore
+     private int restoreWindowWidth;
+     private int restoreWindowHeight;
+     private int restoreWindowX;
+     private int restoreWindowY;
+     /**
+      * Hidden flag.  A hidden window will still have its onIdle() called, and
+      * will also have onClose() called at application exit.  Note package
+      * private access: TApplication will force hidden false if a modal window
+      * is active.
+      */
+     boolean hidden = false;
+     /**
+      * A window may have a status bar associated with it.  TApplication will
+      * draw this status bar last, and will also route events to it first
+      * before the window.
+      */
+     protected TStatusBar statusBar = null;
+     /**
+      * A window may request that TApplication NOT draw the mouse cursor over
+      * it by setting this to true.  This is currently only used within Jexer
+      * by TTerminalWindow so that only the bottom-most instance of nested
+      * Jexer's draws the mouse within its application window.  But perhaps
+      * other applications can use it, so public getter/setter is provided.
+      */
+     private boolean hideMouse = false;
+     // ------------------------------------------------------------------------
+     // Constructors -----------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Public constructor.  Window will be located at (0, 0).
+      *
+      * @param application TApplication that manages this window
+      * @param title window title, will be centered along the top border
+      * @param width width of window
+      * @param height height of window
+      */
+     public TWindow(final TApplication application, final String title,
+         final int width, final int height) {
+         this(application, title, 0, 0, width, height, RESIZABLE);
+     }
+     /**
+      * Public constructor.  Window will be located at (0, 0).
+      *
+      * @param application TApplication that manages this window
+      * @param title window title, will be centered along the top border
+      * @param width width of window
+      * @param height height of window
+      * @param flags bitmask of RESIZABLE, CENTERED, or MODAL
+      */
+     public TWindow(final TApplication application, final String title,
+         final int width, final int height, final int flags) {
+         this(application, title, 0, 0, width, height, flags);
+     }
+     /**
+      * Public constructor.
+      *
+      * @param application TApplication that manages this window
+      * @param title window title, will be centered along the top border
+      * @param x column relative to parent
+      * @param y row relative to parent
+      * @param width width of window
+      * @param height height of window
+      */
+     public TWindow(final TApplication application, final String title,
+         final int x, final int y, final int width, final int height) {
+         this(application, title, x, y, width, height, RESIZABLE);
+     }
+     /**
+      * Public constructor.
+      *
+      * @param application TApplication that manages this window
+      * @param title window title, will be centered along the top border
+      * @param x column relative to parent
+      * @param y row relative to parent
+      * @param width width of window
+      * @param height height of window
+      * @param flags mask of RESIZABLE, CENTERED, or MODAL
+      */
+     public TWindow(final TApplication application, final String title,
+         final int x, final int y, final int width, final int height,
+         final int flags) {
+         super();
+         // I am my own window and parent
+         setupForTWindow(this, x, y + application.getDesktopTop(),
+             width, height);
+         // Save fields
+         this.title       = title;
+         this.application = application;
+         this.flags       = flags;
+         // Minimum width/height are 10 and 2
+         assert (width >= 10);
+         assert (getHeight() >= 2);
+         // MODAL implies CENTERED
+         if (isModal()) {
+             this.flags |= CENTERED;
+         }
+         // Center window if specified
+         center();
+         // Add me to the application
+         application.addWindowToApplication(this);
+     }
+     // ------------------------------------------------------------------------
+     // Event handlers ---------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Returns true if the mouse is currently on the close button.
+      *
+      * @return true if mouse is currently on the close button
+      */
+     protected boolean mouseOnClose() {
+         if ((flags & NOCLOSEBOX) != 0) {
+             return false;
+         }
+         if ((mouse != null)
+             && (mouse.getAbsoluteY() == getY())
+             && (mouse.getAbsoluteX() == getX() + 3)
+         ) {
+             return true;
+         }
+         return false;
+     }
+     /**
+      * Returns true if the mouse is currently on the maximize/restore button.
+      *
+      * @return true if the mouse is currently on the maximize/restore button
+      */
+     protected boolean mouseOnMaximize() {
+         if ((flags & NOZOOMBOX) != 0) {
+             return false;
+         }
+         if ((mouse != null)
+             && !isModal()
+             && (mouse.getAbsoluteY() == getY())
+             && (mouse.getAbsoluteX() == getX() + getWidth() - 4)
+         ) {
+             return true;
+         }
+         return false;
+     }
+     /**
+      * Returns true if the mouse is currently on the resizable lower right
+      * corner.
+      *
+      * @return true if the mouse is currently on the resizable lower right
+      * corner
+      */
+     protected boolean mouseOnResize() {
+         if (((flags & RESIZABLE) != 0)
+             && !isModal()
+             && (mouse != null)
+             && (mouse.getAbsoluteY() == getY() + getHeight() - 1)
+             && ((mouse.getAbsoluteX() == getX() + getWidth() - 1)
+                 || (mouse.getAbsoluteX() == getX() + getWidth() - 2))
+         ) {
+             return true;
+         }
+         return false;
+     }
+     /**
+      * Subclasses should override this method to perform any user prompting
+      * before they are offscreen.  Note that unlike other windowing toolkits,
+      * windows can NOT use this function in some manner to avoid being
+      * closed.  This is called by application.closeWindow().
+      */
+     protected void onPreClose() {
+         // Default: do nothing.
+     }
+     /**
+      * Subclasses should override this method to cleanup resources.  This is
+      * called by application.closeWindow().
+      */
+     protected void onClose() {
+         // Default: perform widget-specific cleanup.
+         for (TWidget w: getChildren()) {
+             w.close();
+         }
+     }
+     /**
+      * Called by application.switchWindow() when this window gets the
+      * focus, and also by application.addWindow().
+      */
+     protected void onFocus() {
+         // Default: do nothing
+     }
+     /**
+      * Called by application.switchWindow() when another window gets the
+      * focus.
+      */
+     protected void onUnfocus() {
+         // Default: do nothing
+     }
+     /**
+      * Called by application.hideWindow().
+      */
+     protected void onHide() {
+         // Default: do nothing
+     }
+     /**
+      * Called by application.showWindow().
+      */
+     protected void onShow() {
+         // Default: do nothing
+     }
+     /**
+      * Handle mouse button presses.
+      *
+      * @param mouse mouse button event
+      */
+     @Override
+     public void onMouseDown(final TMouseEvent mouse) {
+         this.mouse = mouse;
+         inKeyboardResize = false;
+         inWindowMove = false;
+         inWindowResize = false;
+         if ((mouse.getAbsoluteY() == getY())
+             && mouse.isMouse1()
+             && (getX() <= mouse.getAbsoluteX())
+             && (mouse.getAbsoluteX() < getX() + getWidth())
+             && !mouseOnClose()
+             && !mouseOnMaximize()
+         ) {
+             // Begin moving window
+             inWindowMove = true;
+             moveWindowMouseX = mouse.getAbsoluteX();
+             moveWindowMouseY = mouse.getAbsoluteY();
+             oldWindowX = getX();
+             oldWindowY = getY();
+             if (maximized) {
+                 maximized = false;
+             }
+             return;
+         }
+         if (mouseOnResize()) {
+             // Begin window resize
+             inWindowResize = true;
+             moveWindowMouseX = mouse.getAbsoluteX();
+             moveWindowMouseY = mouse.getAbsoluteY();
+             resizeWindowWidth = getWidth();
+             resizeWindowHeight = getHeight();
+             if (maximized) {
+                 maximized = false;
+             }
+             return;
+         }
+         // Give the shortcut bar a shot at this.
+         if (statusBar != null) {
+             if (statusBar.statusBarMouseDown(mouse)) {
+                 return;
+             }
+         }
+         // I didn't take it, pass it on to my children
+         super.onMouseDown(mouse);
+     }
+     /**
+      * Handle mouse button releases.
+      *
+      * @param mouse mouse button release event
+      */
+     @Override
+     public void onMouseUp(final TMouseEvent mouse) {
+         this.mouse = mouse;
+         if ((inWindowMove) && (mouse.isMouse1())) {
+             // Stop moving window
+             inWindowMove = false;
+             return;
+         }
+         if ((inWindowResize) && (mouse.isMouse1())) {
+             // Stop resizing window
+             inWindowResize = false;
+             return;
+         }
+         if (mouse.isMouse1() && mouseOnClose()) {
+             if ((flags & HIDEONCLOSE) == 0) {
+                 // Close window
+                 application.closeWindow(this);
+             } else {
+                 // Hide window
+                 application.hideWindow(this);
+             }
+             return;
+         }
+         if ((mouse.getAbsoluteY() == getY())
+             && mouse.isMouse1()
+             && mouseOnMaximize()) {
+             if (maximized) {
+                 // Restore
+                 restore();
+             } else {
+                 // Maximize
+                 maximize();
+             }
+             // Pass a resize event to my children
+             onResize(new TResizeEvent(TResizeEvent.Type.WIDGET,
+                     getWidth(), getHeight()));
+             return;
+         }
+         // Give the shortcut bar a shot at this.
+         if (statusBar != null) {
+             if (statusBar.statusBarMouseUp(mouse)) {
+                 return;
+             }
+         }
+         // I didn't take it, pass it on to my children
+         super.onMouseUp(mouse);
+     }
+     /**
+      * Handle mouse movements.
+      *
+      * @param mouse mouse motion event
+      */
+     @Override
+     public void onMouseMotion(final TMouseEvent mouse) {
+         this.mouse = mouse;
+         if (inWindowMove) {
+             // Move window over
+             setX(oldWindowX + (mouse.getAbsoluteX() - moveWindowMouseX));
+             setY(oldWindowY + (mouse.getAbsoluteY() - moveWindowMouseY));
+             // Don't cover up the menu bar
+             if (getY() < application.getDesktopTop()) {
+                 setY(application.getDesktopTop());
+             }
+             // Don't go below the status bar
+             if (getY() >= application.getDesktopBottom()) {
+                 setY(application.getDesktopBottom() - 1);
+             }
+             return;
+         }
+         if (inWindowResize) {
+             // Do not permit resizing below the status line
+             if (mouse.getAbsoluteY() == application.getDesktopBottom()) {
+                 inWindowResize = false;
+                 return;
+             }
+             // Move window over
+             setWidth(resizeWindowWidth + (mouse.getAbsoluteX()
+                     - moveWindowMouseX));
+             setHeight(resizeWindowHeight + (mouse.getAbsoluteY()
+                     - moveWindowMouseY));
+             if (getX() + getWidth() > getScreen().getWidth()) {
+                 setWidth(getScreen().getWidth() - getX());
+             }
+             if (getY() + getHeight() > application.getDesktopBottom()) {
+                 setY(application.getDesktopBottom() - getHeight() + 1);
+             }
+             // Don't cover up the menu bar
+             if (getY() < application.getDesktopTop()) {
+                 setY(application.getDesktopTop());
+             }
+             // Keep within min/max bounds
+             if (getWidth() < minimumWindowWidth) {
+                 setWidth(minimumWindowWidth);
+                 inWindowResize = false;
+             }
+             if (getHeight() < minimumWindowHeight) {
+                 setHeight(minimumWindowHeight);
+                 inWindowResize = false;
+             }
+             if ((maximumWindowWidth > 0)
+                 && (getWidth() > maximumWindowWidth)
+             ) {
+                 setWidth(maximumWindowWidth);
+                 inWindowResize = false;
+             }
+             if ((maximumWindowHeight > 0)
+                 && (getHeight() > maximumWindowHeight)
+             ) {
+                 setHeight(maximumWindowHeight);
+                 inWindowResize = false;
+             }
+             // Pass a resize event to my children
+             onResize(new TResizeEvent(TResizeEvent.Type.WIDGET,
+                     getWidth(), getHeight()));
+             return;
+         }
+         // Give the shortcut bar a shot at this.
+         if (statusBar != null) {
+             statusBar.statusBarMouseMotion(mouse);
+         }
+         // I didn't take it, pass it on to my children
+         super.onMouseMotion(mouse);
+     }
+     /**
+      * Handle keystrokes.
+      *
+      * @param keypress keystroke event
+      */
+     @Override
+     public void onKeypress(final TKeypressEvent keypress) {
+         if (inWindowMove || inWindowResize) {
+             // ESC or ENTER - Exit size/move
+             if (keypress.equals(kbEsc) || keypress.equals(kbEnter)) {
+                 inWindowMove = false;
+                 inWindowResize = false;
+                 return;
+             }
+         }
+         if (inKeyboardResize) {
+             // ESC or ENTER - Exit size/move
+             if (keypress.equals(kbEsc) || keypress.equals(kbEnter)) {
+                 inKeyboardResize = false;
+             }
+             if (keypress.equals(kbLeft)) {
+                 if (getX() > 0) {
+                     setX(getX() - 1);
+                 }
+             }
+             if (keypress.equals(kbRight)) {
+                 if (getX() < getScreen().getWidth() - 1) {
+                     setX(getX() + 1);
+                 }
+             }
+             if (keypress.equals(kbDown)) {
+                 if (getY() < application.getDesktopBottom() - 1) {
+                     setY(getY() + 1);
+                 }
+             }
+             if (keypress.equals(kbUp)) {
+                 if (getY() > 1) {
+                     setY(getY() - 1);
+                 }
+             }
+             /*
+              * Only permit keyboard resizing if the window was RESIZABLE.
+              */
+             if ((flags & RESIZABLE) != 0) {
+                 if (keypress.equals(kbShiftLeft)) {
+                     if ((getWidth() > minimumWindowWidth)
+                         || (minimumWindowWidth <= 0)
+                     ) {
+                         setWidth(getWidth() - 1);
+                     }
+                 }
+                 if (keypress.equals(kbShiftRight)) {
+                     if ((getWidth() < maximumWindowWidth)
+                         || (maximumWindowWidth <= 0)
+                     ) {
+                         setWidth(getWidth() + 1);
+                     }
+                 }
+                 if (keypress.equals(kbShiftUp)) {
+                     if ((getHeight() > minimumWindowHeight)
+                         || (minimumWindowHeight <= 0)
+                     ) {
+                         setHeight(getHeight() - 1);
+                     }
+                 }
+                 if (keypress.equals(kbShiftDown)) {
+                     if ((getHeight() < maximumWindowHeight)
+                         || (maximumWindowHeight <= 0)
+                     ) {
+                         setHeight(getHeight() + 1);
+                     }
+                 }
+                 // Pass a resize event to my children
+                 onResize(new TResizeEvent(TResizeEvent.Type.WIDGET,
+                         getWidth(), getHeight()));
+             } // if ((flags & RESIZABLE) != 0)
+             return;
+         }
+         // Give the shortcut bar a shot at this.
+         if (statusBar != null) {
+             if (statusBar.statusBarKeypress(keypress)) {
+                 return;
+             }
+         }
+         // These keystrokes will typically not be seen unless a subclass
+         // overrides onMenu() due to how TApplication dispatches
+         // accelerators.
+         if (!(this instanceof TDesktop)) {
+             // Ctrl-W - close window
+             if (keypress.equals(kbCtrlW)) {
+                 if ((flags & NOCLOSEBOX) == 0) {
+                     if ((flags & HIDEONCLOSE) == 0) {
+                         // Close window
+                         application.closeWindow(this);
+                     } else {
+                         // Hide window
+                         application.hideWindow(this);
+                     }
+                 }
+                 return;
+             }
+             // F6 - behave like Alt-TAB
+             if (keypress.equals(kbF6)) {
+                 application.switchWindow(true);
+                 return;
+             }
+             // Shift-F6 - behave like Shift-Alt-TAB
+             if (keypress.equals(kbShiftF6)) {
+                 application.switchWindow(false);
+                 return;
+             }
+             // F5 - zoom
+             if (keypress.equals(kbF5) && ((flags & NOZOOMBOX) == 0)) {
+                 if (maximized) {
+                     restore();
+                 } else {
+                     maximize();
+                 }
+             }
+             // Ctrl-F5 - size/move
+             if (keypress.equals(kbCtrlF5)) {
+                 inKeyboardResize = !inKeyboardResize;
+             }
+         } // if (!(this instanceof TDesktop))
+         // I didn't take it, pass it on to my children
+         super.onKeypress(keypress);
+     }
+     /**
+      * Handle posted command events.
+      *
+      * @param command command event
+      */
+     @Override
+     public void onCommand(final TCommandEvent command) {
+         // These commands will typically not be seen unless a subclass
+         // overrides onMenu() due to how TApplication dispatches
+         // accelerators.
+         if (!(this instanceof TDesktop)) {
+             if (command.equals(cmWindowClose)) {
+                 if ((flags & NOCLOSEBOX) == 0) {
+                     if ((flags & HIDEONCLOSE) == 0) {
+                         // Close window
+                         application.closeWindow(this);
+                     } else {
+                         // Hide window
+                         application.hideWindow(this);
+                     }
+                 }
+                 return;
+             }
+             if (command.equals(cmWindowNext)) {
+                 application.switchWindow(true);
+                 return;
+             }
+             if (command.equals(cmWindowPrevious)) {
+                 application.switchWindow(false);
+                 return;
+             }
+             if (command.equals(cmWindowMove)) {
+                 inKeyboardResize = true;
+                 return;
+             }
+             if (command.equals(cmWindowZoom) && ((flags & NOZOOMBOX) == 0)) {
+                 if (maximized) {
+                     restore();
+                 } else {
+                     maximize();
+                 }
+             }
+         } // if (!(this instanceof TDesktop))
+         // I didn't take it, pass it on to my children
+         super.onCommand(command);
+     }
+     /**
+      * Handle posted menu events.
+      *
+      * @param menu menu event
+      */
+     @Override
+     public void onMenu(final TMenuEvent menu) {
+         if (!(this instanceof TDesktop)) {
+             if (menu.getId() == TMenu.MID_WINDOW_CLOSE) {
+                 if ((flags & NOCLOSEBOX) == 0) {
+                     if ((flags & HIDEONCLOSE) == 0) {
+                         // Close window
+                         application.closeWindow(this);
+                     } else {
+                         // Hide window
+                         application.hideWindow(this);
+                     }
+                 }
+                 return;
+             }
+             if (menu.getId() == TMenu.MID_WINDOW_NEXT) {
+                 application.switchWindow(true);
+                 return;
+             }
+             if (menu.getId() == TMenu.MID_WINDOW_PREVIOUS) {
+                 application.switchWindow(false);
+                 return;
+             }
+             if (menu.getId() == TMenu.MID_WINDOW_MOVE) {
+                 inKeyboardResize = true;
+                 return;
+             }
+             if ((menu.getId() == TMenu.MID_WINDOW_ZOOM)
+                 && ((flags & NOZOOMBOX) == 0)
+             ) {
+                 if (maximized) {
+                     restore();
+                 } else {
+                     maximize();
+                 }
+                 return;
+             }
+         } // if (!(this instanceof TDesktop))
+         // I didn't take it, pass it on to my children
+         super.onMenu(menu);
+     }
+     /**
+      * Method that subclasses can override to handle window/screen resize
+      * events.
+      *
+      * @param resize resize event
+      */
+     @Override
+     public void onResize(final TResizeEvent resize) {
+         if (resize.getType() == TResizeEvent.Type.WIDGET) {
+             if (getChildren().size() == 1) {
+                 TWidget child = getChildren().get(0);
+                 if ((child instanceof TSplitPane)
+                     || (child instanceof TPanel)
+                 ) {
+                     if (this instanceof TDesktop) {
+                         child.onResize(new TResizeEvent(
+                             TResizeEvent.Type.WIDGET,
+                             resize.getWidth(), resize.getHeight()));
+                     } else {
+                         child.onResize(new TResizeEvent(
+                             TResizeEvent.Type.WIDGET,
+                             resize.getWidth() - 2, resize.getHeight() - 2));
+                     }
+                 }
+                 return;
+             }
+         }
+         // Pass on to TWidget.
+         super.onResize(resize);
+     }
+     // ------------------------------------------------------------------------
+     // TWidget ----------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Get this TWindow's parent TApplication.
+      *
+      * @return this TWindow's parent TApplication
+      */
+     @Override
+     public final TApplication getApplication() {
+         return application;
+     }
+     /**
+      * Get the Screen.
+      *
+      * @return the Screen
+      */
+     @Override
+     public final Screen getScreen() {
+         return application.getScreen();
+     }
+     /**
+      * Called by TApplication.drawChildren() to render on screen.
+      */
+     @Override
+     public void draw() {
+         // Draw the box and background first.
+         CellAttributes border = getBorder();
+         CellAttributes background = getBackground();
+         int borderType = getBorderType();
+         drawBox(0, 0, getWidth(), getHeight(), border, background, borderType,
+             true);
+         // Draw the title
+         int titleLength = StringUtils.width(title);
+         int titleLeft = (getWidth() - titleLength - 2) / 2;
+         putCharXY(titleLeft, 0, ' ', border);
+         putStringXY(titleLeft + 1, 0, title, border);
+         putCharXY(titleLeft + titleLength + 1, 0, ' ', border);
+         if (isActive()) {
+             // Draw the close button
+             if ((flags & NOCLOSEBOX) == 0) {
+                 putCharXY(2, 0, '[', border);
+                 putCharXY(4, 0, ']', border);
+                 if (mouseOnClose() && mouse.isMouse1()) {
+                     putCharXY(3, 0, GraphicsChars.CP437[0x0F],
+                         getBorderControls());
+                 } else {
+                     putCharXY(3, 0, GraphicsChars.CP437[0xFE],
+                         getBorderControls());
+                 }
+             }
+             // Draw the maximize button
+             if (!isModal() && ((flags & NOZOOMBOX) == 0)) {
+                 putCharXY(getWidth() - 5, 0, '[', border);
+                 putCharXY(getWidth() - 3, 0, ']', border);
+                 if (mouseOnMaximize() && mouse.isMouse1()) {
+                     putCharXY(getWidth() - 4, 0, GraphicsChars.CP437[0x0F],
+                         getBorderControls());
+                 } else {
+                     if (maximized) {
+                         putCharXY(getWidth() - 4, 0, GraphicsChars.CP437[0x12],
+                             getBorderControls());
+                     } else {
+                         putCharXY(getWidth() - 4, 0, GraphicsChars.UPARROW,
+                             getBorderControls());
+                     }
+                 }
+                 // Draw the resize corner
+                 if ((flags & RESIZABLE) != 0) {
+                     putCharXY(getWidth() - 2, getHeight() - 1,
+                         GraphicsChars.SINGLE_BAR, getBorderControls());
+                     putCharXY(getWidth() - 1, getHeight() - 1,
+                         GraphicsChars.LRCORNER, getBorderControls());
+                 }
+             }
+         }
+     }
+     // ------------------------------------------------------------------------
+     // TWindow ----------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Get window title.
+      *
+      * @return window title
+      */
+     public final String getTitle() {
+         return title;
+     }
+     /**
+      * Set window title.
+      *
+      * @param title new window title
+      */
+     public final void setTitle(final String title) {
+         this.title = title;
+     }
+     /**
+      * Get Z order.  Lower number means more in-front.
+      *
+      * @return Z value.  Lower number means more in-front.
+      */
+     public final int getZ() {
+         return z;
+     }
+     /**
+      * Set Z order.  Lower number means more in-front.
+      *
+      * @param z the new Z value.  Lower number means more in-front.
+      */
+     public final void setZ(final int z) {
+         this.z = z;
+     }
+     /**
+      * Add a keypress to be overridden for this window.
+      *
+      * @param key the key to start taking control of
+      */
+     protected void addShortcutKeypress(final TKeypress key) {
+         keyboardShortcuts.add(key);
+     }
+     /**
+      * Remove a keypress to be overridden for this window.
+      *
+      * @param key the key to stop taking control of
+      */
+     protected void removeShortcutKeypress(final TKeypress key) {
+         keyboardShortcuts.remove(key);
+     }
+     /**
+      * Remove all keypresses to be overridden for this window.
+      */
+     protected void clearShortcutKeypresses() {
+         keyboardShortcuts.clear();
+     }
+     /**
+      * Determine if a keypress is overridden for this window.
+      *
+      * @param key the key to check
+      * @return true if this window wants to process this key on its own
+      */
+     public boolean isShortcutKeypress(final TKeypress key) {
+         return keyboardShortcuts.contains(key);
+     }
+     /**
+      * Get the window's status bar, or null if it does not have one.
+      *
+      * @return the status bar, or null
+      */
+     public TStatusBar getStatusBar() {
+         return statusBar;
+     }
+     /**
+      * Set the window's status bar to a new one.
+      *
+      * @param text the status bar text
+      * @return the status bar
+      */
+     public TStatusBar newStatusBar(final String text) {
+         statusBar = new TStatusBar(this, text);
+         return statusBar;
+     }
+     /**
+      * Set the maximum width for this window.
+      *
+      * @param maximumWindowWidth new maximum width
+      */
+     public final void setMaximumWindowWidth(final int maximumWindowWidth) {
+         if ((maximumWindowWidth != -1)
+             && (maximumWindowWidth < minimumWindowWidth + 1)
+         ) {
+             throw new IllegalArgumentException("Maximum window width cannot " +
+                 "be smaller than minimum window width + 1");
+         }
+         this.maximumWindowWidth = maximumWindowWidth;
+     }
+     /**
+      * Set the minimum width for this window.
+      *
+      * @param minimumWindowWidth new minimum width
+      */
+     public final void setMinimumWindowWidth(final int minimumWindowWidth) {
+         if ((maximumWindowWidth != -1)
+             && (minimumWindowWidth > maximumWindowWidth - 1)
+         ) {
+             throw new IllegalArgumentException("Minimum window width cannot " +
+                 "be larger than maximum window width - 1");
+         }
+         this.minimumWindowWidth = minimumWindowWidth;
+     }
+     /**
+      * Set the maximum height for this window.
+      *
+      * @param maximumWindowHeight new maximum height
+      */
+     public final void setMaximumWindowHeight(final int maximumWindowHeight) {
+         if ((maximumWindowHeight != -1)
+             && (maximumWindowHeight < minimumWindowHeight + 1)
+         ) {
+             throw new IllegalArgumentException("Maximum window height cannot " +
+                 "be smaller than minimum window height + 1");
+         }
+         this.maximumWindowHeight = maximumWindowHeight;
+     }
+     /**
+      * Set the minimum height for this window.
+      *
+      * @param minimumWindowHeight new minimum height
+      */
+     public final void setMinimumWindowHeight(final int minimumWindowHeight) {
+         if ((maximumWindowHeight != -1)
+             && (minimumWindowHeight > maximumWindowHeight - 1)
+         ) {
+             throw new IllegalArgumentException("Minimum window height cannot " +
+                 "be larger than maximum window height - 1");
+         }
+         this.minimumWindowHeight = minimumWindowHeight;
+     }
+     /**
+      * Recenter the window on-screen.
+      */
+     public final void center() {
+         if ((flags & CENTERED) != 0) {
+             if (getWidth() < getScreen().getWidth()) {
+                 setX((getScreen().getWidth() - getWidth()) / 2);
+             } else {
+                 setX(0);
+             }
+             setY(((application.getDesktopBottom()
+                     - application.getDesktopTop()) - getHeight()) / 2);
+             if (getY() < 0) {
+                 setY(0);
+             }
+             setY(getY() + application.getDesktopTop());
+         }
+     }
+     /**
+      * Maximize window.
+      */
+     public void maximize() {
+         if (maximized) {
+             return;
+         }
+         restoreWindowWidth = getWidth();
+         restoreWindowHeight = getHeight();
+         restoreWindowX = getX();
+         restoreWindowY = getY();
+         setWidth(getScreen().getWidth());
+         setHeight(application.getDesktopBottom() - application.getDesktopTop());
+         setX(0);
+         setY(application.getDesktopTop());
+         maximized = true;
+         onResize(new TResizeEvent(TResizeEvent.Type.WIDGET, getWidth(),
+                 getHeight()));
+     }
+     /**
+      * Restore (unmaximize) window.
+      */
+     public void restore() {
+         if (!maximized) {
+             return;
+         }
+         setWidth(restoreWindowWidth);
+         setHeight(restoreWindowHeight);
+         setX(restoreWindowX);
+         setY(restoreWindowY);
+         maximized = false;
+         onResize(new TResizeEvent(TResizeEvent.Type.WIDGET, getWidth(),
+                 getHeight()));
+     }
+     /**
+      * Returns true if this window is hidden.
+      *
+      * @return true if this window is hidden, false if the window is shown
+      */
+     public final boolean isHidden() {
+         return hidden;
+     }
+     /**
+      * Returns true if this window is shown.
+      *
+      * @return true if this window is shown, false if the window is hidden
+      */
+     public final boolean isShown() {
+         return !hidden;
+     }
+     /**
+      * Hide window.  A hidden window will still have its onIdle() called, and
+      * will also have onClose() called at application exit.  Hidden windows
+      * will not receive any other events.
+      */
+     public void hide() {
+         application.hideWindow(this);
+     }
+     /**
+      * Show window.
+      */
+     public void show() {
+         application.showWindow(this);
+     }
+     /**
+      * Activate window (bring to top and receive events).
+      */
+     @Override
+     public void activate() {
+         application.activateWindow(this);
+     }
+     /**
+      * Close window.  Note that windows without a close box can still be
+      * closed by calling the close() method.
+      */
+     @Override
+     public void close() {
+         application.closeWindow(this);
+     }
+     /**
+      * See if this window is undergoing any movement/resize/etc.
+      *
+      * @return true if the window is moving
+      */
+     public boolean inMovements() {
+         if (inWindowResize || inWindowMove || inKeyboardResize) {
+             return true;
+         }
+         return false;
+     }
+     /**
+      * Stop any pending movement/resize/etc.
+      */
+     public void stopMovements() {
+         inWindowResize = false;
+         inWindowMove = false;
+         inKeyboardResize = false;
+     }
+     /**
+      * Returns true if this window is modal.
+      *
+      * @return true if this window is modal
+      */
+     public final boolean isModal() {
+         if ((flags & MODAL) == 0) {
+             return false;
+         }
+         return true;
+     }
+     /**
+      * Returns true if this window has a close box.
+      *
+      * @return true if this window has a close box
+      */
+     public final boolean hasCloseBox() {
+         if ((flags & NOCLOSEBOX) != 0) {
+             return true;
+         }
+         return false;
+     }
+     /**
+      * Returns true if this window has a maximize/zoom box.
+      *
+      * @return true if this window has a maximize/zoom box
+      */
+     public final boolean hasZoomBox() {
+         if ((flags & NOZOOMBOX) != 0) {
+             return true;
+         }
+         return false;
+     }
+     /**
+      * Returns true if this window does not want menus to work while it is
+      * visible.
+      *
+      * @return true if this window does not want menus to work while it is
+      * visible
+      */
+     public final boolean hasOverriddenMenu() {
+         if ((flags & OVERRIDEMENU) != 0) {
+             return true;
+         }
+         return false;
+     }
+     /**
+      * Retrieve the background color.
+      *
+      * @return the background color
+      */
+     public CellAttributes getBackground() {
+         if (!isModal()
+             && (inWindowMove || inWindowResize || inKeyboardResize)
+         ) {
+             assert (isActive());
+             return getTheme().getColor("twindow.background.windowmove");
+         } else if (isModal() && inWindowMove) {
+             assert (isActive());
+             return getTheme().getColor("twindow.background.modal");
+         } else if (isModal()) {
+             if (isActive()) {
+                 return getTheme().getColor("twindow.background.modal");
+             }
+             return getTheme().getColor("twindow.background.modal.inactive");
+         } else if (isActive()) {
+             assert (!isModal());
+             return getTheme().getColor("twindow.background");
+         } else {
+             assert (!isModal());
+             return getTheme().getColor("twindow.background.inactive");
+         }
+     }
+     /**
+      * Retrieve the border color.
+      *
+      * @return the border color
+      */
+     public CellAttributes getBorder() {
+         if (!isModal()
+             && (inWindowMove || inWindowResize || inKeyboardResize)
+         ) {
+             if (!isActive()) {
+                 // The user's terminal never passed a mouse up event, and now
+                 // another window is active but we never finished a drag.
+                 inWindowMove = false;
+                 inWindowResize = false;
+                 inKeyboardResize = false;
+                 return getTheme().getColor("twindow.border.inactive");
+             }
+             return getTheme().getColor("twindow.border.windowmove");
+         } else if (isModal() && inWindowMove) {
+             assert (isActive());
+             return getTheme().getColor("twindow.border.modal.windowmove");
+         } else if (isModal()) {
+             if (isActive()) {
+                 return getTheme().getColor("twindow.border.modal");
+             } else {
+                 return getTheme().getColor("twindow.border.modal.inactive");
+             }
+         } else if (isActive()) {
+             assert (!isModal());
+             return getTheme().getColor("twindow.border");
+         } else {
+             assert (!isModal());
+             return getTheme().getColor("twindow.border.inactive");
+         }
+     }
+     /**
+      * Retrieve the color used by the window movement/sizing controls.
+      *
+      * @return the color used by the zoom box, resize bar, and close box
+      */
+     public CellAttributes getBorderControls() {
+         if (isModal()) {
+             return getTheme().getColor("twindow.border.modal.windowmove");
+         }
+         return getTheme().getColor("twindow.border.windowmove");
+     }
+     /**
+      * Retrieve the border line type.
+      *
+      * @return the border line type
+      */
+     private int getBorderType() {
+         if (!isModal()
+             && (inWindowMove || inWindowResize || inKeyboardResize)
+         ) {
+             assert (isActive());
+             return 1;
+         } else if (isModal() && inWindowMove) {
+             assert (isActive());
+             return 1;
+         } else if (isModal()) {
+             if (isActive()) {
+                 return 2;
+             } else {
+                 return 1;
+             }
+         } else if (isActive()) {
+             return 2;
+         } else {
+             return 1;
+         }
+     }
+     /**
+      * Returns true if this window does not want the application-wide mouse
+      * cursor drawn over it.
+      *
+      * @return true if this window does not want the application-wide mouse
+      * cursor drawn over it
+      */
+     public boolean hasHiddenMouse() {
+         return hideMouse;
+     }
+     /**
+      * Set request to prevent the application-wide mouse cursor from being
+      * drawn over this window.
+      *
+      * @param hideMouse if true, this window does not want the
+      * application-wide mouse cursor drawn over it
+      */
+     public final void setHiddenMouse(final boolean hideMouse) {
+         this.hideMouse = hideMouse;
+     }
+     /**
+      * Generate a human-readable string for this window.
+      *
+      * @return a human-readable string
+      */
+     @Override
+     public String toString() {
+         return String.format("%s(%8x) \'%s\' position (%d, %d) geometry %dx%d" +
+             " hidden %s modal %s", getClass().getName(), hashCode(), title,
+             getX(), getY(), getWidth(), getHeight(), hidden, isModal());
+     }
+ }
index 0000000000000000000000000000000000000000,eaed7e64966228e6386d21349c757ae80efaa5e2..eaed7e64966228e6386d21349c757ae80efaa5e2
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,104 +1,104 @@@
+ /*
+  * Jexer - Java Text User Interface
+  *
+  * The MIT License (MIT)
+  *
+  * 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"),
+  * to deal in the Software without restriction, including without limitation
+  * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+  * and/or sell copies of the Software, and to permit persons to whom the
+  * Software is furnished to do so, subject to the following conditions:
+  *
+  * The above copyright notice and this permission notice shall be included in
+  * all copies or substantial portions of the Software.
+  *
+  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+  * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+  * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+  * DEALINGS IN THE SOFTWARE.
+  *
+  * @author Kevin Lamonte [kevin.lamonte@gmail.com]
+  * @version 1
+  */
+ package jexer.backend;
+ import java.util.List;
+ import jexer.event.TInputEvent;
+ /**
+  * This interface provides a screen, keyboard, and mouse to TApplication.  It
+  * also exposes session information as gleaned from lower levels of the
+  * communication stack.
+  */
+ public interface Backend {
+     /**
+      * Get a SessionInfo, which exposes text width/height, language,
+      * username, and other information from the communication stack.
+      *
+      * @return the SessionInfo
+      */
+     public SessionInfo getSessionInfo();
+     /**
+      * Get a Screen, which displays the text cells to the user.
+      *
+      * @return the Screen
+      */
+     public Screen getScreen();
+     /**
+      * Classes must provide an implementation that syncs the logical screen
+      * to the physical device.
+      */
+     public void flushScreen();
+     /**
+      * Check if there are events in the queue.
+      *
+      * @return if true, getEvents() has something to return to the application
+      */
+     public boolean hasEvents();
+     /**
+      * Classes must provide an implementation to get keyboard, mouse, and
+      * screen resize events.
+      *
+      * @param queue list to append new events to
+      */
+     public void getEvents(List<TInputEvent> queue);
+     /**
+      * Classes must provide an implementation that closes sockets, restores
+      * console, etc.
+      */
+     public void shutdown();
+     /**
+      * Classes must provide an implementation that sets the window title.
+      *
+      * @param title the new title
+      */
+     public void setTitle(final String title);
+     /**
+      * 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);
+     /**
+      * Reload backend options from System properties.
+      */
+     public void reloadOptions();
+ }
index 0000000000000000000000000000000000000000,0614e17d9ccc6bb5ba690da0bbd30d39ec3f5851..0614e17d9ccc6bb5ba690da0bbd30d39ec3f5851
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,168 +1,168 @@@
+ /*
+  * Jexer - Java Text User Interface
+  *
+  * The MIT License (MIT)
+  *
+  * 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"),
+  * to deal in the Software without restriction, including without limitation
+  * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+  * and/or sell copies of the Software, and to permit persons to whom the
+  * Software is furnished to do so, subject to the following conditions:
+  *
+  * The above copyright notice and this permission notice shall be included in
+  * all copies or substantial portions of the Software.
+  *
+  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+  * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+  * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+  * DEALINGS IN THE SOFTWARE.
+  *
+  * @author Kevin Lamonte [kevin.lamonte@gmail.com]
+  * @version 1
+  */
+ package jexer.backend;
+ import java.io.InputStream;
+ import java.io.OutputStream;
+ import java.io.PrintWriter;
+ import java.io.Reader;
+ import java.io.UnsupportedEncodingException;
+ /**
+  * This class uses an xterm/ANSI X3.64/ECMA-48 type terminal to provide a
+  * screen, keyboard, and mouse to TApplication.
+  */
+ public class ECMA48Backend extends GenericBackend {
+     // ------------------------------------------------------------------------
+     // Constructors -----------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Public constructor will use System.in and System.out and UTF-8
+      * encoding. On non-Windows systems System.in will be put in raw mode;
+      * shutdown() will (blindly!) put System.in in cooked mode.
+      *
+      * @throws UnsupportedEncodingException if an exception is thrown when
+      * creating the InputStreamReader
+      */
+     public ECMA48Backend() throws UnsupportedEncodingException {
+         this(null, null, null);
+     }
+     /**
+      * Public constructor.
+      *
+      * @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.
+      * @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
+      * @param fontSize the size in points.  ECMA48 cannot set it, but it is
+      * here to match the Swing API.
+      * @throws UnsupportedEncodingException if an exception is thrown when
+      * creating the InputStreamReader
+      */
+     public ECMA48Backend(final Object listener, final InputStream input,
+         final OutputStream output, final int windowWidth,
+         final int windowHeight, final int fontSize)
+         throws UnsupportedEncodingException {
+         // Create a terminal and explicitly set stdin into raw mode
+         terminal = new ECMA48Terminal(listener, input, output, windowWidth,
+             windowHeight);
+         // Keep the terminal's sessionInfo so that TApplication can see it
+         sessionInfo = ((ECMA48Terminal) terminal).getSessionInfo();
+         // ECMA48Terminal is the screen too
+         screen = (ECMA48Terminal) terminal;
+     }
+     /**
+      * Public constructor.
+      *
+      * @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.
+      * @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 ECMA48Backend(final Object listener, final InputStream input,
+         final OutputStream output) throws UnsupportedEncodingException {
+         // Create a terminal and explicitly set stdin into raw mode
+         terminal = new ECMA48Terminal(listener, input, output);
+         // Keep the terminal's sessionInfo so that TApplication can see it
+         sessionInfo = ((ECMA48Terminal) terminal).getSessionInfo();
+         // ECMA48Terminal is the screen too
+         screen = (ECMA48Terminal) terminal;
+     }
+     /**
+      * Public constructor.
+      *
+      * @param listener the object this backend needs to wake up when new
+      * input comes in
+      * @param input the InputStream underlying 'reader'.  Its available()
+      * method is used to determine if reader.read() will block or not.
+      * @param reader a Reader connected to the remote user.
+      * @param writer a PrintWriter connected to the remote user.
+      * @param setRawMode if true, set System.in into raw mode with stty.
+      * This should in general not be used.  It is here solely for Demo3,
+      * which uses System.in.
+      * @throws IllegalArgumentException if input, reader, or writer are null.
+      */
+     public ECMA48Backend(final Object listener, final InputStream input,
+         final Reader reader, final PrintWriter writer,
+         final boolean setRawMode) {
+         // Create a terminal and explicitly set stdin into raw mode
+         terminal = new ECMA48Terminal(listener, input, reader, writer,
+             setRawMode);
+         // Keep the terminal's sessionInfo so that TApplication can see it
+         sessionInfo = ((ECMA48Terminal) terminal).getSessionInfo();
+         // ECMA48Terminal is the screen too
+         screen = (ECMA48Terminal) terminal;
+     }
+     /**
+      * Public constructor.
+      *
+      * @param listener the object this backend needs to wake up when new
+      * input comes in
+      * @param input the InputStream underlying 'reader'.  Its available()
+      * method is used to determine if reader.read() will block or not.
+      * @param reader a Reader connected to the remote user.
+      * @param writer a PrintWriter connected to the remote user.
+      * @throws IllegalArgumentException if input, reader, or writer are null.
+      */
+     public ECMA48Backend(final Object listener, final InputStream input,
+         final Reader reader, final PrintWriter writer) {
+         this(listener, input, reader, writer, false);
+     }
+ }
index 0000000000000000000000000000000000000000,e2997d2f6b17486356ddd9d902d114039258bdef..e2997d2f6b17486356ddd9d902d114039258bdef
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,4384 +1,4384 @@@
+ /*
+  * Jexer - Java Text User Interface
+  *
+  * The MIT License (MIT)
+  *
+  * 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"),
+  * to deal in the Software without restriction, including without limitation
+  * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+  * and/or sell copies of the Software, and to permit persons to whom the
+  * Software is furnished to do so, subject to the following conditions:
+  *
+  * The above copyright notice and this permission notice shall be included in
+  * all copies or substantial portions of the Software.
+  *
+  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+  * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+  * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+  * DEALINGS IN THE SOFTWARE.
+  *
+  * @author Kevin Lamonte [kevin.lamonte@gmail.com]
+  * @version 1
+  */
+ package jexer.backend;
+ 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;
+ import java.io.InputStreamReader;
+ import java.io.IOException;
+ import java.io.OutputStream;
+ import java.io.OutputStreamWriter;
+ 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 javax.imageio.ImageIO;
+ import jexer.TImage;
+ import jexer.bits.Cell;
+ import jexer.bits.CellAttributes;
+ import jexer.bits.Color;
+ 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 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,
+     }
+     // ------------------------------------------------------------------------
+     // Variables --------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Emit debugging to stderr.
+      */
+     private boolean debugToStderr = false;
+     /**
+      * 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;
+     /**
+      * The session information.
+      */
+     private SessionInfo sessionInfo;
+     /**
+      * The event queue, filled up by a thread reading on input.
+      */
+     private List<TInputEvent> eventQueue;
+     /**
+      * If true, we want the reader thread to exit gracefully.
+      */
+     private boolean stopReaderThread;
+     /**
+      * The reader thread.
+      */
+     private Thread readerThread;
+     /**
+      * 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 List<String> params;
+     /**
+      * Current parsing state.
+      */
+     private ParseState state;
+     /**
+      * The time we entered ESCAPE.  If we get a bare escape without a code
+      * following it, this is used to return that bare escape.
+      */
+     private long escapeTime;
+     /**
+      * The time we last checked the window size.  We try not to spawn stty
+      * more than once per second.
+      */
+     private long windowSizeTime;
+     /**
+      * true if mouse1 was down.  Used to report mouse1 on the release event.
+      */
+     private boolean mouse1;
+     /**
+      * true if mouse2 was down.  Used to report mouse2 on the release event.
+      */
+     private boolean mouse2;
+     /**
+      * true if mouse3 was down.  Used to report mouse3 on the release event.
+      */
+     private boolean mouse3;
+     /**
+      * Cache the cursor visibility value so we only emit the sequence when we
+      * need to.
+      */
+     private boolean cursorOn = true;
+     /**
+      * Cache the last window size to figure out if a TResizeEvent needs to be
+      * generated.
+      */
+     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;
+     /**
+      * 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 true, emit image data via Jexer image protocol.
+      */
+     private boolean jexerImages = false;
+     /**
+      * The Jexer post-rendered string cache.
+      */
+     private ImageCache jexerCache = null;
+     /**
+      * Base64 encoder used by iTerm2 and Jexer images.
+      */
+     private java.util.Base64.Encoder base64 = null;
+     /**
+      * If true, then we changed System.in and need to change it back.
+      */
+     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
+      * constructor, then this InputStreamReader will be bound to System.in
+      * with UTF-8 encoding.
+      */
+     private Reader input;
+     /**
+      * The terminal's raw InputStream.  If an InputStream is not specified in
+      * the constructor, then this InputReader will be bound to System.in.
+      * This is used by run() to see if bytes are available() before calling
+      * (Reader)input.read().
+      */
+     private InputStream inputStream;
+     /**
+      * The terminal's output.  If an OutputStream is not specified in the
+      * constructor, then this PrintWriter will be bound to System.out with
+      * UTF-8 encoding.
+      */
+     private PrintWriter output;
+     /**
+      * The listening object that run() wakes up on new input.
+      */
+     private Object 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;
+     /**
+      * SixelPalette is used to manage the conversion of images between 24-bit
+      * RGB color and a palette of sixelPaletteSize colors.
+      */
+     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;
+             }
+         }
+         /**
+          * Public constructor.
+          */
+         public SixelPalette() {
+             makePalette();
+         }
+         /**
+          * 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;
+             }
+             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();
+         }
+     }
+     /**
+      * 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 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();
+             }
+         }
+         /**
+          * 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());
+             }
+             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;
+             }
+             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());
+                  */
+             }
+             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().  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; 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, 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();
+         }
+     }
+     /**
+      * 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 {
+             inputStream = input;
+         }
+         this.input = new InputStreamReader(inputStream, "UTF-8");
+         if (input instanceof SessionInfo) {
+             // This is a TelnetInputStream that exposes window size and
+             // environment variables from the telnet layer.
+             sessionInfo = (SessionInfo) input;
+         }
+         if (sessionInfo == null) {
+             if (input == null) {
+                 // Reading right off the tty
+                 sessionInfo = new TTYSessionInfo();
+             } else {
+                 sessionInfo = new TSessionInfo();
+             }
+         }
+         if (output == null) {
+             this.output = new PrintWriter(new OutputStreamWriter(System.out,
+                     "UTF-8"));
+         } else {
+             this.output = new PrintWriter(new OutputStreamWriter(output,
+                     "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));
+         this.output.flush();
+         // Request xterm use the sixel settings we want
+         this.output.printf("%s", xtermSetSixelSettings());
+         // Query the screen size
+         sessionInfo.queryWindowSize();
+         setDimensions(sessionInfo.getWindowWidth(),
+             sessionInfo.getWindowHeight());
+         // Hang onto the window size
+         windowResize = new TResizeEvent(TResizeEvent.Type.SCREEN,
+             sessionInfo.getWindowWidth(), sessionInfo.getWindowHeight());
+         reloadOptions();
+         // Spin up the input reader
+         eventQueue = new ArrayList<TInputEvent>();
+         readerThread = new Thread(this);
+         readerThread.start();
+         // Clear the screen
+         this.output.write(clearAll());
+         this.output.flush();
+     }
+     /**
+      * Constructor sets up state for getEvent().
+      *
+      * @param listener the object this backend needs to wake up when new
+      * input comes in
+      * @param input the InputStream underlying 'reader'.  Its available()
+      * method is used to determine if reader.read() will block or not.
+      * @param reader a Reader connected to the remote user.
+      * @param writer a PrintWriter connected to the remote user.
+      * @param setRawMode if true, set System.in into raw mode with stty.
+      * This should in general not be used.  It is here solely for Demo3,
+      * which uses System.in.
+      * @throws IllegalArgumentException if input, reader, or writer are null.
+      */
+     public ECMA48Terminal(final Object listener, final InputStream input,
+         final Reader reader, final PrintWriter writer,
+         final boolean setRawMode) {
+         if (input == null) {
+             throw new IllegalArgumentException("InputStream must be specified");
+         }
+         if (reader == null) {
+             throw new IllegalArgumentException("Reader must be specified");
+         }
+         if (writer == null) {
+             throw new IllegalArgumentException("Writer must be specified");
+         }
+         resetParser();
+         mouse1           = false;
+         mouse2           = false;
+         mouse3           = false;
+         stopReaderThread = false;
+         this.listener    = listener;
+         inputStream = input;
+         this.input = reader;
+         if (setRawMode == true) {
+             sttyRaw();
+         }
+         this.setRawMode = setRawMode;
+         if (input instanceof SessionInfo) {
+             // This is a TelnetInputStream that exposes window size and
+             // environment variables from the telnet layer.
+             sessionInfo = (SessionInfo) input;
+         }
+         if (sessionInfo == null) {
+             if (setRawMode == true) {
+                 // Reading right off the tty
+                 sessionInfo = new TTYSessionInfo();
+             } else {
+                 sessionInfo = new TSessionInfo();
+             }
+         }
+         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));
+         this.output.flush();
+         // Request xterm use the sixel settings we want
+         this.output.printf("%s", xtermSetSixelSettings());
+         // Query the screen size
+         sessionInfo.queryWindowSize();
+         setDimensions(sessionInfo.getWindowWidth(),
+             sessionInfo.getWindowHeight());
+         // Hang onto the window size
+         windowResize = new TResizeEvent(TResizeEvent.Type.SCREEN,
+             sessionInfo.getWindowWidth(), sessionInfo.getWindowHeight());
+         reloadOptions();
+         // Spin up the input reader
+         eventQueue = new ArrayList<TInputEvent>();
+         readerThread = new Thread(this);
+         readerThread.start();
+         // Clear the screen
+         this.output.write(clearAll());
+         this.output.flush();
+     }
+     /**
+      * Constructor sets up state for getEvent().
+      *
+      * @param listener the object this backend needs to wake up when new
+      * input comes in
+      * @param input the InputStream underlying 'reader'.  Its available()
+      * method is used to determine if reader.read() will block or not.
+      * @param reader a Reader connected to the remote user.
+      * @param writer a PrintWriter connected to the remote user.
+      * @throws IllegalArgumentException if input, reader, or writer are null.
+      */
+     public ECMA48Terminal(final Object listener, final InputStream input,
+         final Reader reader, final PrintWriter writer) {
+         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("=== closeTerminal() ==="); System.err.flush();
+         // Tell the reader thread to stop looking at input
+         stopReaderThread = true;
+         try {
+             readerThread.join();
+         } catch (InterruptedException e) {
+             if (debugToStderr) {
+                 e.printStackTrace();
+             }
+         }
+         // 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();
+             setRawMode = false;
+             // We don't close System.in/out
+         } else {
+             // Shut down the streams, this should wake up the reader thread
+             // and make it exit.
+             if (input != null) {
+                 try {
+                     input.close();
+                 } catch (IOException e) {
+                     // SQUASH
+                 }
+                 input = null;
+             }
+             if (output != null) {
+                 output.close();
+                 output = null;
+             }
+         }
+     }
+     /**
+      * 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;
+     }
+     /**
+      * Reload options from System properties.
+      */
+     public void reloadOptions() {
+         // Permit RGB colors only if externally requested.
+         if (System.getProperty("jexer.ECMA48.rgbColor",
+                 "false").equals("true")
+         ) {
+             doRgbColor = true;
+         } else {
+             doRgbColor = false;
+         }
+         // Default to using images for full-width characters.
+         if (System.getProperty("jexer.ECMA48.wideCharImages",
+                 "true").equals("true")) {
+             wideCharImages = true;
+         } else {
+             wideCharImages = false;
+         }
+         // Pull the system properties for sixel output.
+         if (System.getProperty("jexer.ECMA48.sixel", "true").equals("true")) {
+             sixel = true;
+         } else {
+             sixel = false;
+         }
+         // 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
+         }
+         // Default to using images for full-width characters.
+         if (System.getProperty("jexer.ECMA48.iTerm2Images",
+                 "false").equals("true")) {
+             iterm2Images = true;
+         } else {
+             iterm2Images = false;
+         }
+         // Set custom colors
+         setCustomSystemColors();
+     }
+     // ------------------------------------------------------------------------
+     // Runnable ---------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * 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>();
+         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();
+                     }
+                     if (output.checkError()) {
+                         // This is EOF.
+                         done = true;
+                     }
+                     // 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))
+         // 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();
+             }
+         }
+         // System.err.println("*** run() exiting..."); System.err.flush();
+     }
+     // ------------------------------------------------------------------------
+     // ECMA48Terminal ---------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Get the width of a character cell in pixels.
+      *
+      * @return the width in pixels of a character cell
+      */
+     public int getTextWidth() {
+         return (widthPixels / sessionInfo.getWindowWidth());
+     }
+     /**
+      * Get the height of a character cell in pixels.
+      *
+      * @return the height in pixels of a character cell
+      */
+     public int getTextHeight() {
+         return (heightPixels / sessionInfo.getWindowHeight());
+     }
+     /**
+      * Getter for sessionInfo.
+      *
+      * @return the SessionInfo
+      */
+     public SessionInfo getSessionInfo() {
+         return sessionInfo;
+     }
+     /**
+      * Get the output writer.
+      *
+      * @return the Writer
+      */
+     public PrintWriter getOutput() {
+         return output;
+     }
+     /**
+      * Call 'stty' to set cooked mode.
+      *
+      * <p>Actually executes '/bin/sh -c stty sane cooked &lt; /dev/tty'
+      */
+     private void sttyCooked() {
+         doStty(false);
+     }
+     /**
+      * 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);
+     }
+     /**
+      * Call 'stty' to set raw or cooked mode.
+      *
+      * @param mode if true, set raw mode, otherwise set cooked mode
+      */
+     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);
+             }
+             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);
+             }
+             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();
+                     }
+                 }
+             }
+             int rc = process.exitValue();
+             if (rc != 0) {
+                 System.err.println("stty returned error code: " + rc);
+             }
+         } catch (IOException e) {
+             e.printStackTrace();
+         }
+     }
+     /**
+      * Flush output.
+      */
+     public void flush() {
+         output.flush();
+     }
+     /**
+      * 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
+      */
+     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;
+             }
+         }
+         // Push textEnd to first column beyond the text area
+         textEnd++;
+         // DEBUG
+         // reallyCleared = true;
+         boolean hasImage = false;
+         for (int x = 0; x < width; x++) {
+             Cell lCell = logical[x][y];
+             Cell pCell = physical[x][y];
+             if (!lCell.equals(pCell) || reallyCleared) {
+                 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");
+                 }
+                 if (lastAttr == null) {
+                     lastAttr = new CellAttributes();
+                     sb.append(normal());
+                 }
+                 // Place the cell
+                 if ((lastX != (x - 1)) || (lastX == -1)) {
+                     // Advancing at least one cell, or the first gotoXY
+                     sb.append(gotoXY(x, y));
+                 }
+                 assert (lastAttr != null);
+                 if ((x == textEnd) && (textEnd < width - 1)) {
+                     assert (lCell.isBlank());
+                     for (int i = x; i < width; i++) {
+                         assert (logical[i][y].isBlank());
+                         // Physical is always updated
+                         physical[i][y].reset();
+                     }
+                     // 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++)
+     }
+     /**
+      * 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 sb StringBuilder to write escape sequences to
+      * @return escape sequences string that provides the updates to the
+      * physical screen
+      */
+     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;
+                 }
+             }
+         }
+         for (int y = 0; y < height; y++) {
+             for (int x = 0; x < width; x++) {
+                 Cell lCell = logical[x][y];
+                 Cell pCell = physical[x][y];
+                 if (!lCell.isImage()
+                     || (!wideCharImages
+                         && (lCell.getWidth() != Cell.Width.SINGLE))
+                 ) {
+                     continue;
+                 }
+                 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]);
+                     // Physical is always updated.
+                     physical[x + i][y].setTo(lCell);
+                 }
+                 if (cellsToDraw.size() > 0) {
+                     if (iterm2Images) {
+                         sb.append(toIterm2Image(x, y, cellsToDraw));
+                     } else if (jexerImages) {
+                         sb.append(toJexerImage(x, y, cellsToDraw));
+                     } else {
+                         sb.append(toSixel(x, y, cellsToDraw));
+                     }
+                 }
+                 x = right;
+             }
+         }
+         // Draw the text part now.
+         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);
+         }
+         return result;
+     }
+     /**
+      * Reset keyboard/mouse input parser.
+      */
+     private void resetParser() {
+         state = ParseState.GROUND;
+         params = new ArrayList<String>();
+         params.clear();
+         params.add("");
+         decPrivateModeFlag = false;
+     }
+     /**
+      * 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);
+         }
+     }
+     /**
+      * Produce special key from CSI Pn ; Pm ; ... ~
+      *
+      * @return one KEYPRESS event representing a special key
+      */
+     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;
+         }
+     }
+     /**
+      * 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;
+         // 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;
+             } else {
+                 eventType = TMouseEvent.Type.MOUSE_UP;
+             }
+             if (mouse1) {
+                 mouse1 = false;
+                 eventMouse1 = true;
+             }
+             if (mouse2) {
+                 mouse2 = false;
+                 eventMouse2 = true;
+             }
+             if (mouse3) {
+                 mouse3 = false;
+                 eventMouse3 = true;
+             }
+             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;
+         }
+         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
+      *
+      * @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;
+         if (release) {
+             eventType = TMouseEvent.Type.MOUSE_UP;
+         }
+         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;
+         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;
+         }
+         return new TMouseEvent(eventType, x, y, x, y,
+             eventMouse1, eventMouse2, eventMouse3,
+             eventMouseWheelUp, eventMouseWheelDown);
+     }
+     /**
+      * 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
+                     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
+                     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 'c':
+                     // Device Attributes
+                     if (decPrivateModeFlag == false) {
+                         break;
+                     }
+                     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");
+                             }
+                             jexerImages = true;
+                         }
+                     }
+                     return;
+                 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;
+         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)
+      *
+      * @return the string to emit to xterm
+      */
+     private String xtermSetSixelSettings() {
+         return "\033[?80h\033[?1070l";
+     }
+     /**
+      * 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();
+             // TODO: make this an option (shared palette or not)
+             palette.emitPalette(sb, null);
+         }
+         return sb.toString();
+     }
+     /**
+      * 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(' ');
+             }
+             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.
+             // TODO: support sixel scrolling mode disabled as an option.
+             sb.append(normal());
+             sb.append(gotoXY(x, y));
+             for (int j = 0; j < cells.size(); j++) {
+                 sb.append(' ');
+             }
+             return sb.toString();
+         }
+         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");
+         }
+         int imageWidth = cells.get(0).getImage().getWidth();
+         int imageHeight = cells.get(0).getImage().getHeight();
+         // cells.get(x).getImage() has a dithered bitmap containing indexes
+         // into the color palette.  Piece these together into one larger
+         // image for final rendering.
+         int totalWidth = 0;
+         int fullWidth = cells.size() * getTextWidth();
+         int fullHeight = getTextHeight();
+         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 = Math.min(cells.get(i).getImage().getWidth(),
+                 imageWidth);
+             int tileHeight = Math.min(cells.get(i).getImage().getHeight(),
+                 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);
+                     }
+                 }
+             }
+         }
+         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 < getTextWidth()) {
+             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);
+                 }
+             }
+         }
+         // Dither the image.  It is ok to lose the original here.
+         if (palette == null) {
+             palette = new SixelPalette();
+             // TODO: make this an option (shared palette or not)
+             palette.emitPalette(sb, null);
+         }
+         image = palette.ditherImage(image);
+         // Collect the raster information
+         int rasterHeight = 0;
+         int rasterWidth = image.getWidth();
+         /*
+         // TODO: make this an option (shared palette or not)
+         // 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);
+          */
+         // 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;
+                 }
+             }
+             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;
+                         }
+                     }
+                 }
+                 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;
+                             }
+                         }
+                     }
+                     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;
+                     }
+                 } // 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;
+     }
+     // ------------------------------------------------------------------------
+     // 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);
+             base64 = java.util.Base64.getEncoder();
+         }
+         // 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");
+         }
+         int imageWidth = cells.get(0).getImage().getWidth();
+         int imageHeight = cells.get(0).getImage().getHeight();
+         // cells.get(x).getImage() has a dithered bitmap containing indexes
+         // into the color palette.  Piece these together into one larger
+         // image for final rendering.
+         int totalWidth = 0;
+         int fullWidth = cells.size() * getTextWidth();
+         int fullHeight = getTextHeight();
+         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 = Math.min(cells.get(i).getImage().getWidth(),
+                 imageWidth);
+             int tileHeight = Math.min(cells.get(i).getImage().getHeight(),
+                 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);
+                     }
+                 }
+             }
+         }
+         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 < getTextWidth()) {
+             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);
+                 }
+             }
+         }
+         /*
+          * 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 "";
+         }
+         // iTerm2 does not advance the cursor automatically, so place it
+         // myself.
+         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(base64.encodeToString(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 (jexerImages == false) {
+             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);
+             base64 = java.util.Base64.getEncoder();
+         }
+         // 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");
+         }
+         int imageWidth = cells.get(0).getImage().getWidth();
+         int imageHeight = cells.get(0).getImage().getHeight();
+         // cells.get(x).getImage() has a dithered bitmap containing indexes
+         // into the color palette.  Piece these together into one larger
+         // image for final rendering.
+         int totalWidth = 0;
+         int fullWidth = cells.size() * getTextWidth();
+         int fullHeight = getTextHeight();
+         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 = Math.min(cells.get(i).getImage().getWidth(),
+                 imageWidth);
+             int tileHeight = Math.min(cells.get(i).getImage().getHeight(),
+                 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);
+                     }
+                 }
+             }
+         }
+         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 < getTextWidth()) {
+             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);
+                 }
+             }
+         }
+         sb.append(String.format("\033]444;%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(base64.encodeToString(bytes));
+         sb.append("\007");
+         if (saveInCache) {
+             // This row is OK to save into the cache.
+             jexerCache.put(cells, sb.toString());
+         }
+         return (gotoXY(x, y) + sb.toString());
+     }
+     /**
+      * Get the Jexer images support flag.
+      *
+      * @return true if this terminal is emitting Jexer images
+      */
+     public boolean hasJexerImages() {
+         return jexerImages;
+     }
+     // ------------------------------------------------------------------------
+     // 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;
+         }
+         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 a T.416 RGB parameter sequence for a custom system color.
+      *
+      * @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 systemColorRGB(final java.awt.Color color) {
+         return String.format("%d;%d;%d", color.getRed(), color.getGreen(),
+             color.getBlue());
+     }
+     /**
+      * Create a SGR parameter sequence for a single color change.
+      *
+      * @param bold if true, set bold
+      * @param color one of the Color.WHITE, Color.BLUE, etc. constants
+      * @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 color(final boolean bold, final Color color,
+         final boolean foreground) {
+         return color(color, foreground, true) +
+                 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.
+      *
+      * @param bold if true, set bold
+      * @param color one of the Color.WHITE, Color.BLUE, etc. constants
+      * @param foreground if true, this is a foreground color
+      * @return the string to emit to an xterm terminal with RGB support,
+      * e.g. "\033[38;2;RR;GG;BBm"
+      */
+     private String rgbColor(final boolean bold, final Color color,
+         final boolean foreground) {
+         if (doRgbColor == false) {
+             return "";
+         }
+         StringBuilder sb = new StringBuilder("\033[");
+         if (bold) {
+             // Bold implies foreground only
+             sb.append("38;2;");
+             if (color.equals(Color.BLACK)) {
+                 sb.append(systemColorRGB(MYBOLD_BLACK));
+             } else if (color.equals(Color.RED)) {
+                 sb.append(systemColorRGB(MYBOLD_RED));
+             } else if (color.equals(Color.GREEN)) {
+                 sb.append(systemColorRGB(MYBOLD_GREEN));
+             } else if (color.equals(Color.YELLOW)) {
+                 sb.append(systemColorRGB(MYBOLD_YELLOW));
+             } else if (color.equals(Color.BLUE)) {
+                 sb.append(systemColorRGB(MYBOLD_BLUE));
+             } else if (color.equals(Color.MAGENTA)) {
+                 sb.append(systemColorRGB(MYBOLD_MAGENTA));
+             } else if (color.equals(Color.CYAN)) {
+                 sb.append(systemColorRGB(MYBOLD_CYAN));
+             } else if (color.equals(Color.WHITE)) {
+                 sb.append(systemColorRGB(MYBOLD_WHITE));
+             }
+         } else {
+             if (foreground) {
+                 sb.append("38;2;");
+             } else {
+                 sb.append("48;2;");
+             }
+             if (color.equals(Color.BLACK)) {
+                 sb.append(systemColorRGB(MYBLACK));
+             } else if (color.equals(Color.RED)) {
+                 sb.append(systemColorRGB(MYRED));
+             } else if (color.equals(Color.GREEN)) {
+                 sb.append(systemColorRGB(MYGREEN));
+             } else if (color.equals(Color.YELLOW)) {
+                 sb.append(systemColorRGB(MYYELLOW));
+             } else if (color.equals(Color.BLUE)) {
+                 sb.append(systemColorRGB(MYBLUE));
+             } else if (color.equals(Color.MAGENTA)) {
+                 sb.append(systemColorRGB(MYMAGENTA));
+             } else if (color.equals(Color.CYAN)) {
+                 sb.append(systemColorRGB(MYCYAN));
+             } else if (color.equals(Color.WHITE)) {
+                 sb.append(systemColorRGB(MYWHITE));
+             }
+         }
+         sb.append("m");
+         return sb.toString();
+     }
+     /**
+      * Create a T.416 RGB parameter sequence for both foreground and
+      * background color change.
+      *
+      * @param bold if true, set bold
+      * @param foreColor one of the Color.WHITE, Color.BLUE, etc. constants
+      * @param backColor one of the Color.WHITE, Color.BLUE, etc. constants
+      * @return the string to emit to an xterm terminal with RGB support,
+      * e.g. "\033[38;2;RR;GG;BB;48;2;RR;GG;BBm"
+      */
+     private String rgbColor(final boolean bold, final Color foreColor,
+         final Color backColor) {
+         if (doRgbColor == false) {
+             return "";
+         }
+         return rgbColor(bold, foreColor, true) +
+                 rgbColor(false, backColor, false);
+     }
+     /**
+      * Create a SGR parameter sequence for a single color change.
+      *
+      * @param color one of the Color.WHITE, Color.BLUE, etc. constants
+      * @param foreground if true, this is a foreground color
+      * @param header if true, make the full header, otherwise just emit the
+      * color parameter e.g. "42;"
+      * @return the string to emit to an ANSI / ECMA-style terminal,
+      * e.g. "\033[42m"
+      */
+     private String color(final Color color, final boolean foreground,
+         final boolean header) {
+         int ecmaColor = color.getValue();
+         // Convert Color.* values to SGR numerics
+         if (foreground) {
+             ecmaColor += 30;
+         } else {
+             ecmaColor += 40;
+         }
+         if (header) {
+             return String.format("\033[%dm", ecmaColor);
+         } else {
+             return String.format("%d;", ecmaColor);
+         }
+     }
+     /**
+      * Create a SGR parameter sequence for both foreground and background
+      * color change.
+      *
+      * @param bold if true, set bold
+      * @param foreColor one of the Color.WHITE, Color.BLUE, etc. constants
+      * @param backColor one of the Color.WHITE, Color.BLUE, etc. constants
+      * @return the string to emit to an ANSI / ECMA-style terminal,
+      * e.g. "\033[31;42m"
+      */
+     private String color(final boolean bold, final Color foreColor,
+         final Color backColor) {
+         return color(foreColor, backColor, true) +
+                 rgbColor(bold, foreColor, backColor);
+     }
+     /**
+      * Create a SGR parameter sequence for both foreground and
+      * background color change.
+      *
+      * @param foreColor one of the Color.WHITE, Color.BLUE, etc. constants
+      * @param backColor one of the Color.WHITE, Color.BLUE, etc. constants
+      * @param header if true, make the full header, otherwise just emit the
+      * color parameter e.g. "31;42;"
+      * @return the string to emit to an ANSI / ECMA-style terminal,
+      * e.g. "\033[31;42m"
+      */
+     private String color(final Color foreColor, final Color backColor,
+         final boolean header) {
+         int ecmaForeColor = foreColor.getValue();
+         int ecmaBackColor = backColor.getValue();
+         // Convert Color.* values to SGR numerics
+         ecmaBackColor += 40;
+         ecmaForeColor += 30;
+         if (header) {
+             return String.format("\033[%d;%dm", ecmaForeColor, ecmaBackColor);
+         } else {
+             return String.format("%d;%d;", ecmaForeColor, ecmaBackColor);
+         }
+     }
+     /**
+      * 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 foreColor one of the Color.WHITE, Color.BLUE, etc. constants
+      * @param backColor one of the Color.WHITE, Color.BLUE, etc. constants
+      * @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 color(final Color foreColor, final Color backColor,
+         final boolean bold, final boolean reverse, final boolean blink,
+         final boolean underline) {
+         int ecmaForeColor = foreColor.getValue();
+         int ecmaBackColor = backColor.getValue();
+         // Convert Color.* values to SGR numerics
+         ecmaBackColor += 40;
+         ecmaForeColor += 30;
+         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(String.format("%d;%dm", ecmaForeColor, ecmaBackColor));
+         sb.append(rgbColor(bold, foreColor, backColor));
+         return sb.toString();
+     }
+     /**
+      * 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"
+      */
+     private String normal() {
+         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.
+      *
+      * @param header if true, make the full header, otherwise just emit the
+      * bare parameter e.g. "0;"
+      * @return the string to emit to an ANSI / ECMA-style terminal,
+      * e.g. "\033[0m"
+      */
+     private String normal(final boolean header) {
+         if (header) {
+             return "\033[0;37;40m";
+         }
+         return "0;37;40";
+     }
+     /**
+      * Create a SGR parameter sequence for enabling the visible cursor.
+      *
+      * @param on if true, turn on cursor
+      * @return the string to emit to an ANSI / ECMA-style terminal
+      */
+     private String cursor(final boolean on) {
+         if (on && !cursorOn) {
+             cursorOn = true;
+             return "\033[?25h";
+         }
+         if (!on && cursorOn) {
+             cursorOn = false;
+             return "\033[?25l";
+         }
+         return "";
+     }
+     /**
+      * Clear the entire screen.  Because some terminals use back-color-erase,
+      * set the color to white-on-black beforehand.
+      *
+      * @return the string to emit to an ANSI / ECMA-style terminal
+      */
+     private String clearAll() {
+         return "\033[0;37;40m\033[2J";
+     }
+     /**
+      * Clear the line from the cursor (inclusive) to the end of the screen.
+      * Because some terminals use back-color-erase, set the color to
+      * white-on-black beforehand.
+      *
+      * @return the string to emit to an ANSI / ECMA-style terminal
+      */
+     private String clearRemainingLine() {
+         return "\033[0;37;40m\033[K";
+     }
+     /**
+      * Move the cursor to (x, y).
+      *
+      * @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 gotoXY(final int x, final int y) {
+         return String.format("\033[%d;%dH", y + 1, x + 1);
+     }
+     /**
+      * Tell (u)xterm that we want to receive mouse events based on "Any event
+      * tracking", UTF-8 coordinates, and then SGR coordinates.  Ideally we
+      * will end up with SGR coordinates with UTF-8 coordinates as a fallback.
+      * See
+      * http://invisible-island.net/xterm/ctlseqs/ctlseqs.html#Mouse%20Tracking
+      *
+      * 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.
+      * @return the string to emit to xterm
+      */
+     private String mouse(final boolean on) {
+         if (on) {
+             return "\033[?1002;1003;1005;1006h\033[?1049h\033^hideMousePointer\033\\";
+         }
+         return "\033[?1002;1003;1006;1005l\033[?1049l\033^showMousePointer\033\\";
+     }
+ }
index 0000000000000000000000000000000000000000,ede3c0bff4365743f1d8c38d3c4b130f1cdc01f0..ede3c0bff4365743f1d8c38d3c4b130f1cdc01f0
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,171 +1,171 @@@
+ /*
+  * Jexer - Java Text User Interface
+  *
+  * The MIT License (MIT)
+  *
+  * 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"),
+  * to deal in the Software without restriction, including without limitation
+  * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+  * and/or sell copies of the Software, and to permit persons to whom the
+  * Software is furnished to do so, subject to the following conditions:
+  *
+  * The above copyright notice and this permission notice shall be included in
+  * all copies or substantial portions of the Software.
+  *
+  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+  * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+  * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+  * DEALINGS IN THE SOFTWARE.
+  *
+  * @author Kevin Lamonte [kevin.lamonte@gmail.com]
+  * @version 1
+  */
+ package jexer.backend;
+ import java.util.List;
+ import jexer.event.TInputEvent;
+ import jexer.event.TCommandEvent;
+ import static jexer.TCommand.*;
+ /**
+  * This abstract class provides a screen, keyboard, and mouse to
+  * TApplication.  It also exposes session information as gleaned from lower
+  * levels of the communication stack.
+  */
+ public abstract class GenericBackend implements Backend {
+     // ------------------------------------------------------------------------
+     // Variables --------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * The session information.
+      */
+     protected SessionInfo sessionInfo;
+     /**
+      * The screen to draw on.
+      */
+     protected Screen screen;
+     /**
+      * Input events are processed by this Terminal.
+      */
+     protected TerminalReader terminal;
+     /**
+      * By default, GenericBackend adds a cmAbort after it sees
+      * cmBackendDisconnect, so that TApplication will exit when the user
+      * closes the Swing window or disconnects the ECMA48 streams.  But
+      * MultiBackend wraps multiple Backends, and needs to decide when to send
+      * cmAbort differently.  Setting this to false is how it manages that.
+      * Note package private access.
+      */
+     boolean abortOnDisconnect = true;
+     // ------------------------------------------------------------------------
+     // Constructors -----------------------------------------------------------
+     // ------------------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     // Backend ----------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Getter for sessionInfo.
+      *
+      * @return the SessionInfo
+      */
+     public final SessionInfo getSessionInfo() {
+         return sessionInfo;
+     }
+     /**
+      * Getter for screen.
+      *
+      * @return the Screen
+      */
+     public final Screen getScreen() {
+         return screen;
+     }
+     /**
+      * Sync the logical screen to the physical device.
+      */
+     public void flushScreen() {
+         screen.flushPhysical();
+     }
+     /**
+      * Check if there are events in the queue.
+      *
+      * @return if true, getEvents() has something to return to the application
+      */
+     public boolean hasEvents() {
+         return terminal.hasEvents();
+     }
+     /**
+      * Get keyboard, mouse, and screen resize events.
+      *
+      * @param queue list to append new events to
+      */
+     public void getEvents(final List<TInputEvent> queue) {
+         if (terminal.hasEvents()) {
+             terminal.getEvents(queue);
+             // This default backend assumes a single user, and if that user
+             // becomes disconnected we should terminate the application.
+             if ((queue.size() > 0) && (abortOnDisconnect == true)) {
+                 TInputEvent event = queue.get(queue.size() - 1);
+                 if (event instanceof TCommandEvent) {
+                     TCommandEvent command = (TCommandEvent) event;
+                     if (command.equals(cmBackendDisconnect)) {
+                         queue.add(new TCommandEvent(cmAbort));
+                     }
+                 }
+             }
+         }
+     }
+     /**
+      * Close the I/O, restore the console, etc.
+      */
+     public void shutdown() {
+         terminal.closeTerminal();
+     }
+     /**
+      * Set the window title.
+      *
+      * @param title the new title
+      */
+     public void setTitle(final String title) {
+         screen.setTitle(title);
+     }
+     /**
+      * 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) {
+         terminal.setListener(listener);
+     }
+     /**
+      * Reload backend options from System properties.
+      */
+     public void reloadOptions() {
+         terminal.reloadOptions();
+     }
+ }
index 0000000000000000000000000000000000000000,0da2918def6c8d9d0f57d7a77506828039994d69..0da2918def6c8d9d0f57d7a77506828039994d69
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,472 +1,472 @@@
+ /*
+  * Jexer - Java Text User Interface
+  *
+  * The MIT License (MIT)
+  *
+  * 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"),
+  * to deal in the Software without restriction, including without limitation
+  * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+  * and/or sell copies of the Software, and to permit persons to whom the
+  * Software is furnished to do so, subject to the following conditions:
+  *
+  * The above copyright notice and this permission notice shall be included in
+  * all copies or substantial portions of the Software.
+  *
+  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+  * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+  * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+  * DEALINGS IN THE SOFTWARE.
+  *
+  * @author Kevin Lamonte [kevin.lamonte@gmail.com]
+  * @version 1
+  */
+ package jexer.backend;
+ import java.awt.Font;
+ import java.awt.FontMetrics;
+ import java.awt.Graphics2D;
+ import java.awt.geom.Rectangle2D;
+ import java.awt.image.BufferedImage;
+ import java.io.InputStream;
+ import java.io.IOException;
+ import java.util.HashMap;
+ import jexer.bits.Cell;
+ import jexer.bits.StringUtils;
+ /**
+  * GlyphMakerFont creates glyphs as bitmaps from a font.
+  */
+ class GlyphMakerFont {
+     // ------------------------------------------------------------------------
+     // Constants --------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     // Variables --------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * If true, enable debug messages.
+      */
+     private static boolean DEBUG = false;
+     /**
+      * If true, we were successful at getting the font dimensions.
+      */
+     private boolean gotFontDimensions = false;
+     /**
+      * The currently selected font.
+      */
+     private Font font = null;
+     /**
+      * Width of a character cell in pixels.
+      */
+     private int textWidth = 1;
+     /**
+      * Height of a character cell in pixels.
+      */
+     private int textHeight = 1;
+     /**
+      * Width of a character cell in pixels, as reported by font.
+      */
+     private int fontTextWidth = 1;
+     /**
+      * Height of a character cell in pixels, as reported by font.
+      */
+     private int fontTextHeight = 1;
+     /**
+      * Descent of a character cell in pixels.
+      */
+     private int maxDescent = 0;
+     /**
+      * System-dependent Y adjustment for text in the character cell.
+      */
+     private int textAdjustY = 0;
+     /**
+      * System-dependent X adjustment for text in the character cell.
+      */
+     private int textAdjustX = 0;
+     /**
+      * System-dependent height adjustment for text in the character cell.
+      */
+     private int textAdjustHeight = 0;
+     /**
+      * System-dependent width adjustment for text in the character cell.
+      */
+     private int textAdjustWidth = 0;
+     /**
+      * A cache of previously-rendered glyphs for blinking text, when it is
+      * not visible.
+      */
+     private HashMap<Cell, BufferedImage> glyphCacheBlink;
+     /**
+      * A cache of previously-rendered glyphs for non-blinking, or
+      * blinking-and-visible, text.
+      */
+     private HashMap<Cell, BufferedImage> glyphCache;
+     // ------------------------------------------------------------------------
+     // Constructors -----------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Public constructor.
+      *
+      * @param filename the resource filename of the font to use
+      * @param fontSize the size of font to use
+      */
+     public GlyphMakerFont(final String filename, final int fontSize) {
+         if (filename.length() == 0) {
+             // Fallback font
+             font = new Font(Font.MONOSPACED, Font.PLAIN, fontSize);
+             return;
+         }
+         Font fontRoot = null;
+         try {
+             ClassLoader loader = Thread.currentThread().getContextClassLoader();
+             InputStream in = loader.getResourceAsStream(filename);
+             fontRoot = Font.createFont(Font.TRUETYPE_FONT, in);
+             font = fontRoot.deriveFont(Font.PLAIN, fontSize);
+         } catch (java.awt.FontFormatException e) {
+             // Ideally we would report an error here, either via System.err
+             // or TExceptionDialog.  However, I do not want GlyphMaker to
+             // know about available backends, so we quietly fallback to
+             // whatever is available as MONO.
+             font = new Font(Font.MONOSPACED, Font.PLAIN, fontSize);
+         } catch (java.io.IOException e) {
+             // See comment above.
+             font = new Font(Font.MONOSPACED, Font.PLAIN, fontSize);
+         }
+     }
+     // ------------------------------------------------------------------------
+     // GlyphMakerFont ---------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Get a glyph image.
+      *
+      * @param cell the character to draw
+      * @param cellWidth the width of the text cell to draw into
+      * @param cellHeight the height of the text cell to draw into
+      * @return the glyph as an image
+      */
+     public BufferedImage getImage(final Cell cell, final int cellWidth,
+         final int cellHeight) {
+         return getImage(cell, cellWidth, cellHeight, true);
+     }
+     /**
+      * Get a glyph image.
+      *
+      * @param cell the character to draw
+      * @param cellWidth the width of the text cell to draw into
+      * @param cellHeight the height of the text cell to draw into
+      * @param blinkVisible if true, the cell is visible if it is blinking
+      * @return the glyph as an image
+      */
+     public BufferedImage getImage(final Cell cell, final int cellWidth,
+         final int cellHeight, final boolean blinkVisible) {
+         if (gotFontDimensions == false) {
+             // Lazy-load the text width/height and adjustments.
+             getFontDimensions();
+         }
+         if (DEBUG && !font.canDisplay(cell.getChar())) {
+             System.err.println("font " + font + " has no glyph for " +
+                 String.format("0x%x", cell.getChar()));
+         }
+         BufferedImage image = null;
+         if (cell.isBlink() && !blinkVisible) {
+             image = glyphCacheBlink.get(cell);
+         } else {
+             image = glyphCache.get(cell);
+         }
+         if (image != null) {
+             return image;
+         }
+         // Generate glyph and draw it.
+         image = new BufferedImage(cellWidth, cellHeight,
+             BufferedImage.TYPE_INT_ARGB);
+         Graphics2D gr2 = image.createGraphics();
+         gr2.setFont(font);
+         Cell cellColor = new Cell(cell);
+         // Check for reverse
+         if (cell.isReverse()) {
+             cellColor.setForeColor(cell.getBackColor());
+             cellColor.setBackColor(cell.getForeColor());
+         }
+         // Draw the background rectangle, then the foreground character.
+         gr2.setColor(SwingTerminal.attrToBackgroundColor(cellColor));
+         gr2.fillRect(0, 0, cellWidth, cellHeight);
+         // Handle blink and underline
+         if (!cell.isBlink()
+             || (cell.isBlink() && blinkVisible)
+         ) {
+             gr2.setColor(SwingTerminal.attrToForegroundColor(cellColor));
+             char [] chars = Character.toChars(cell.getChar());
+             gr2.drawChars(chars, 0, chars.length, textAdjustX,
+                 cellHeight - maxDescent + textAdjustY);
+             if (cell.isUnderline()) {
+                 gr2.fillRect(0, cellHeight - 2, cellWidth, 2);
+             }
+         }
+         gr2.dispose();
+         // We need a new key that will not be mutated by invertCell().
+         Cell key = new Cell(cell);
+         if (cell.isBlink() && !blinkVisible) {
+             glyphCacheBlink.put(key, image);
+         } else {
+             glyphCache.put(key, image);
+         }
+         /*
+         System.err.println("cellWidth " + cellWidth +
+             " cellHeight " + cellHeight + " image " + image);
+          */
+         return image;
+     }
+     /**
+      * Figure out my font dimensions.
+      */
+     private void getFontDimensions() {
+         glyphCacheBlink = new HashMap<Cell, BufferedImage>();
+         glyphCache = new HashMap<Cell, BufferedImage>();
+         BufferedImage image = new BufferedImage(font.getSize() * 2,
+             font.getSize() * 2, BufferedImage.TYPE_INT_ARGB);
+         Graphics2D gr = image.createGraphics();
+         gr.setFont(font);
+         FontMetrics fm = gr.getFontMetrics();
+         maxDescent = fm.getMaxDescent();
+         Rectangle2D bounds = fm.getMaxCharBounds(gr);
+         int leading = fm.getLeading();
+         fontTextWidth = (int)Math.round(bounds.getWidth());
+         // fontTextHeight = (int)Math.round(bounds.getHeight()) - maxDescent;
+         // This produces the same number, but works better for ugly
+         // monospace.
+         fontTextHeight = fm.getMaxAscent() + maxDescent - leading;
+         gr.dispose();
+         textHeight = fontTextHeight + textAdjustHeight;
+         textWidth = fontTextWidth + textAdjustWidth;
+         /*
+         System.err.println("font " + font);
+         System.err.println("fontTextWidth " + fontTextWidth);
+         System.err.println("fontTextHeight " + fontTextHeight);
+         System.err.println("textWidth " + textWidth);
+         System.err.println("textHeight " + textHeight);
+          */
+         gotFontDimensions = true;
+     }
+     /**
+      * Checks if this maker's Font has a glyph for the specified character.
+      *
+      * @param codePoint the character (Unicode code point) for which a glyph
+      * is needed.
+      * @return true if this Font has a glyph for the character; false
+      * otherwise.
+      */
+     public boolean canDisplay(final int codePoint) {
+         return font.canDisplay(codePoint);
+     }
+ }
+ /**
+  * GlyphMaker presents unified interface to all of its supported fonts to
+  * clients.
+  */
+ public class GlyphMaker {
+     // ------------------------------------------------------------------------
+     // Constants --------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * The mono font resource filename (terminus).
+      */
+     private static final String MONO = "terminus-ttf-4.39/TerminusTTF-Bold-4.39.ttf";
+     /**
+      * The CJK font resource filename.
+      */
+     private static final String cjkFontFilename = "NotoSansMonoCJKtc-Regular.otf";
+     /**
+      * The emoji font resource filename.
+      */
+     private static final String emojiFontFilename = "OpenSansEmoji.ttf";
+     /**
+      * The fallback font resource filename.
+      */
+     private static final String fallbackFontFilename = "";
+     // ------------------------------------------------------------------------
+     // Variables --------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * If true, enable debug messages.
+      */
+     private static boolean DEBUG = false;
+     /**
+      * Cache of font bundles by size.
+      */
+     private static HashMap<Integer, GlyphMaker> makers = new HashMap<Integer, GlyphMaker>();
+     /**
+      * The instance that has the mono (default) font.
+      */
+     private GlyphMakerFont makerMono;
+     /**
+      * The instance that has the CJK font.
+      */
+     private GlyphMakerFont makerCjk;
+     /**
+      * The instance that has the emoji font.
+      */
+     private GlyphMakerFont makerEmoji;
+     /**
+      * The instance that has the fallback font.
+      */
+     private GlyphMakerFont makerFallback;
+     // ------------------------------------------------------------------------
+     // Constructors -----------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Create an instance with references to the necessary fonts.
+      *
+      * @param fontSize the size of these fonts in pixels
+      */
+     private GlyphMaker(final int fontSize) {
+         makerMono = new GlyphMakerFont(MONO, fontSize);
+         String fontFilename = null;
+         fontFilename = System.getProperty("jexer.cjkFont.filename",
+             cjkFontFilename);
+         makerCjk = new GlyphMakerFont(fontFilename, fontSize);
+         fontFilename = System.getProperty("jexer.emojiFont.filename",
+             emojiFontFilename);
+         makerEmoji = new GlyphMakerFont(fontFilename, fontSize);
+         fontFilename = System.getProperty("jexer.fallbackFont.filename",
+             fallbackFontFilename);
+         makerFallback = new GlyphMakerFont(fontFilename, fontSize);
+     }
+     // ------------------------------------------------------------------------
+     // GlyphMaker -------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Obtain the GlyphMaker instance for a particular font size.
+      *
+      * @param fontSize the size of these fonts in pixels
+      * @return the instance
+      */
+     public static GlyphMaker getInstance(final int fontSize) {
+         synchronized (GlyphMaker.class) {
+             GlyphMaker maker = makers.get(fontSize);
+             if (maker == null) {
+                 maker = new GlyphMaker(fontSize);
+                 makers.put(fontSize, maker);
+             }
+             return maker;
+         }
+     }
+     /**
+      * Get a glyph image.
+      *
+      * @param cell the character to draw
+      * @param cellWidth the width of the text cell to draw into
+      * @param cellHeight the height of the text cell to draw into
+      * @return the glyph as an image
+      */
+     public BufferedImage getImage(final Cell cell, final int cellWidth,
+         final int cellHeight) {
+         return getImage(cell, cellWidth, cellHeight, true);
+     }
+     /**
+      * Get a glyph image.
+      *
+      * @param cell the character to draw
+      * @param cellWidth the width of the text cell to draw into
+      * @param cellHeight the height of the text cell to draw into
+      * @param blinkVisible if true, the cell is visible if it is blinking
+      * @return the glyph as an image
+      */
+     public BufferedImage getImage(final Cell cell, final int cellWidth,
+         final int cellHeight, final boolean blinkVisible) {
+         int ch = cell.getChar();
+         if (StringUtils.isCjk(ch)) {
+             if (makerCjk.canDisplay(ch)) {
+                 return makerCjk.getImage(cell, cellWidth, cellHeight,
+                     blinkVisible);
+             }
+         }
+         if (StringUtils.isEmoji(ch)) {
+             if (makerEmoji.canDisplay(ch)) {
+                 // System.err.println("emoji: " + String.format("0x%x", ch));
+                 return makerEmoji.getImage(cell, cellWidth, cellHeight,
+                     blinkVisible);
+             }
+         }
+         // When all else fails, use the default.
+         if (makerMono.canDisplay(ch)) {
+             return makerMono.getImage(cell, cellWidth, cellHeight,
+                 blinkVisible);
+         }
+         return makerFallback.getImage(cell, cellWidth, cellHeight,
+             blinkVisible);
+     }
+ }
index 0000000000000000000000000000000000000000,4e4aecca7a349eb999b83a9ef4bc71c229ff650a..4e4aecca7a349eb999b83a9ef4bc71c229ff650a
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,1045 +1,1045 @@@
+ /*
+  * Jexer - Java Text User Interface
+  *
+  * The MIT License (MIT)
+  *
+  * 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"),
+  * to deal in the Software without restriction, including without limitation
+  * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+  * and/or sell copies of the Software, and to permit persons to whom the
+  * Software is furnished to do so, subject to the following conditions:
+  *
+  * The above copyright notice and this permission notice shall be included in
+  * all copies or substantial portions of the Software.
+  *
+  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+  * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+  * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+  * DEALINGS IN THE SOFTWARE.
+  *
+  * @author Kevin Lamonte [kevin.lamonte@gmail.com]
+  * @version 1
+  */
+ package jexer.backend;
+ import java.awt.image.BufferedImage;
+ import jexer.backend.GlyphMaker;
+ import jexer.bits.Cell;
+ import jexer.bits.CellAttributes;
+ import jexer.bits.GraphicsChars;
+ import jexer.bits.StringUtils;
+ /**
+  * A logical screen composed of a 2D array of Cells.
+  */
+ public class LogicalScreen implements Screen {
+     // ------------------------------------------------------------------------
+     // Variables --------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Width of the visible window.
+      */
+     protected int width;
+     /**
+      * Height of the visible window.
+      */
+     protected int height;
+     /**
+      * Drawing offset for x.
+      */
+     private int offsetX;
+     /**
+      * Drawing offset for y.
+      */
+     private int offsetY;
+     /**
+      * Ignore anything drawn right of clipRight.
+      */
+     private int clipRight;
+     /**
+      * Ignore anything drawn below clipBottom.
+      */
+     private int clipBottom;
+     /**
+      * Ignore anything drawn left of clipLeft.
+      */
+     private int clipLeft;
+     /**
+      * Ignore anything drawn above clipTop.
+      */
+     private int clipTop;
+     /**
+      * The physical screen last sent out on flush().
+      */
+     protected Cell [][] physical;
+     /**
+      * The logical screen being rendered to.
+      */
+     protected Cell [][] logical;
+     /**
+      * Set if the user explicitly wants to redraw everything starting with a
+      * ECMATerminal.clearAll().
+      */
+     protected boolean reallyCleared;
+     /**
+      * If true, the cursor is visible and should be placed onscreen at
+      * (cursorX, cursorY) during a call to flushPhysical().
+      */
+     protected boolean cursorVisible;
+     /**
+      * Cursor X position if visible.
+      */
+     protected int cursorX;
+     /**
+      * Cursor Y position if visible.
+      */
+     protected int cursorY;
+     /**
+      * The last used height of a character cell in pixels, only used for
+      * full-width chars.
+      */
+     private int lastTextHeight = -1;
+     /**
+      * The glyph drawer for full-width chars.
+      */
+     private GlyphMaker glyphMaker = null;
+     // ------------------------------------------------------------------------
+     // Constructors -----------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Public constructor.  Sets everything to not-bold, white-on-black.
+      */
+     protected LogicalScreen() {
+         offsetX  = 0;
+         offsetY  = 0;
+         width    = 80;
+         height   = 24;
+         logical  = null;
+         physical = null;
+         reallocate(width, height);
+     }
+     // ------------------------------------------------------------------------
+     // Screen -----------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Get the width of a character cell in pixels.
+      *
+      * @return the width in pixels of a character cell
+      */
+     public int getTextWidth() {
+         // Default width is 16 pixels.
+         return 16;
+     }
+     /**
+      * Get the height of a character cell in pixels.
+      *
+      * @return the height in pixels of a character cell
+      */
+     public int getTextHeight() {
+         // Default height is 20 pixels.
+         return 20;
+     }
+     /**
+      * Set drawing offset for x.
+      *
+      * @param offsetX new drawing offset
+      */
+     public final void setOffsetX(final int offsetX) {
+         this.offsetX = offsetX;
+     }
+     /**
+      * Set drawing offset for y.
+      *
+      * @param offsetY new drawing offset
+      */
+     public final void setOffsetY(final int offsetY) {
+         this.offsetY = offsetY;
+     }
+     /**
+      * Get right drawing clipping boundary.
+      *
+      * @return drawing boundary
+      */
+     public final int getClipRight() {
+         return clipRight;
+     }
+     /**
+      * Set right drawing clipping boundary.
+      *
+      * @param clipRight new boundary
+      */
+     public final void setClipRight(final int clipRight) {
+         this.clipRight = clipRight;
+     }
+     /**
+      * Get bottom drawing clipping boundary.
+      *
+      * @return drawing boundary
+      */
+     public final int getClipBottom() {
+         return clipBottom;
+     }
+     /**
+      * Set bottom drawing clipping boundary.
+      *
+      * @param clipBottom new boundary
+      */
+     public final void setClipBottom(final int clipBottom) {
+         this.clipBottom = clipBottom;
+     }
+     /**
+      * Get left drawing clipping boundary.
+      *
+      * @return drawing boundary
+      */
+     public final int getClipLeft() {
+         return clipLeft;
+     }
+     /**
+      * Set left drawing clipping boundary.
+      *
+      * @param clipLeft new boundary
+      */
+     public final void setClipLeft(final int clipLeft) {
+         this.clipLeft = clipLeft;
+     }
+     /**
+      * Get top drawing clipping boundary.
+      *
+      * @return drawing boundary
+      */
+     public final int getClipTop() {
+         return clipTop;
+     }
+     /**
+      * Set top drawing clipping boundary.
+      *
+      * @param clipTop new boundary
+      */
+     public final void setClipTop(final int clipTop) {
+         this.clipTop = clipTop;
+     }
+     /**
+      * Get dirty flag.
+      *
+      * @return if true, the logical screen is not in sync with the physical
+      * screen
+      */
+     public final boolean isDirty() {
+         for (int x = 0; x < width; x++) {
+             for (int y = 0; y < height; y++) {
+                 if (!logical[x][y].equals(physical[x][y])) {
+                     return true;
+                 }
+                 if (logical[x][y].isBlink()) {
+                     // Blinking screens are always dirty.  There is
+                     // opportunity for a Netscape blink tag joke here...
+                     return true;
+                 }
+             }
+         }
+         return false;
+     }
+     /**
+      * Get the attributes at one location.
+      *
+      * @param x column coordinate.  0 is the left-most column.
+      * @param y row coordinate.  0 is the top-most row.
+      * @return attributes at (x, y)
+      */
+     public final CellAttributes getAttrXY(final int x, final int y) {
+         CellAttributes attr = new CellAttributes();
+         if ((x >= 0) && (x < width) && (y >= 0) && (y < height)) {
+             attr.setTo(logical[x][y]);
+         }
+         return attr;
+     }
+     /**
+      * Get the cell at one location.
+      *
+      * @param x column coordinate.  0 is the left-most column.
+      * @param y row coordinate.  0 is the top-most row.
+      * @return the character + attributes
+      */
+     public Cell getCharXY(final int x, final int y) {
+         Cell cell = new Cell();
+         if ((x >= 0) && (x < width) && (y >= 0) && (y < height)) {
+             cell.setTo(logical[x][y]);
+         }
+         return cell;
+     }
+     /**
+      * Set the attributes at one location.
+      *
+      * @param x column coordinate.  0 is the left-most column.
+      * @param y row coordinate.  0 is the top-most row.
+      * @param attr attributes to use (bold, foreColor, backColor)
+      */
+     public final void putAttrXY(final int x, final int y,
+         final CellAttributes attr) {
+         putAttrXY(x, y, attr, true);
+     }
+     /**
+      * Set the attributes at one location.
+      *
+      * @param x column coordinate.  0 is the left-most column.
+      * @param y row coordinate.  0 is the top-most row.
+      * @param attr attributes to use (bold, foreColor, backColor)
+      * @param clip if true, honor clipping/offset
+      */
+     public final void putAttrXY(final int x, final int y,
+         final CellAttributes attr, final boolean clip) {
+         int X = x;
+         int Y = y;
+         if (clip) {
+             if ((x < clipLeft)
+                 || (x >= clipRight)
+                 || (y < clipTop)
+                 || (y >= clipBottom)
+             ) {
+                 return;
+             }
+             X += offsetX;
+             Y += offsetY;
+         }
+         if ((X >= 0) && (X < width) && (Y >= 0) && (Y < height)) {
+             logical[X][Y].setTo(attr);
+             // If this happens to be the cursor position, make the position
+             // dirty.
+             if ((cursorX == X) && (cursorY == Y)) {
+                 physical[cursorX][cursorY].unset();
+                 unsetImageRow(cursorY);
+             }
+         }
+     }
+     /**
+      * Fill the entire screen with one character with attributes.
+      *
+      * @param ch character to draw
+      * @param attr attributes to use (bold, foreColor, backColor)
+      */
+     public final void putAll(final int ch, final CellAttributes attr) {
+         for (int x = 0; x < width; x++) {
+             for (int y = 0; y < height; y++) {
+                 putCharXY(x, y, ch, attr);
+             }
+         }
+     }
+     /**
+      * Render one character with attributes.
+      *
+      * @param x column coordinate.  0 is the left-most column.
+      * @param y row coordinate.  0 is the top-most row.
+      * @param ch character + attributes to draw
+      */
+     public final void putCharXY(final int x, final int y, final Cell ch) {
+         if ((x < clipLeft)
+             || (x >= clipRight)
+             || (y < clipTop)
+             || (y >= clipBottom)
+         ) {
+             return;
+         }
+         if ((StringUtils.width(ch.getChar()) == 2) && (!ch.isImage())) {
+             putFullwidthCharXY(x, y, ch);
+             return;
+         }
+         int X = x + offsetX;
+         int Y = y + offsetY;
+         // System.err.printf("putCharXY: %d, %d, %c\n", X, Y, ch);
+         if ((X >= 0) && (X < width) && (Y >= 0) && (Y < height)) {
+             // Do not put control characters on the display
+             if (!ch.isImage()) {
+                 assert (ch.getChar() >= 0x20);
+                 assert (ch.getChar() != 0x7F);
+             }
+             logical[X][Y].setTo(ch);
+             // If this happens to be the cursor position, make the position
+             // dirty.
+             if ((cursorX == X) && (cursorY == Y)) {
+                 physical[cursorX][cursorY].unset();
+                 unsetImageRow(cursorY);
+             }
+         }
+     }
+     /**
+      * Render one character with attributes.
+      *
+      * @param x column coordinate.  0 is the left-most column.
+      * @param y row coordinate.  0 is the top-most row.
+      * @param ch character to draw
+      * @param attr attributes to use (bold, foreColor, backColor)
+      */
+     public final void putCharXY(final int x, final int y, final int ch,
+         final CellAttributes attr) {
+         if ((x < clipLeft)
+             || (x >= clipRight)
+             || (y < clipTop)
+             || (y >= clipBottom)
+         ) {
+             return;
+         }
+         if (StringUtils.width(ch) == 2) {
+             putFullwidthCharXY(x, y, ch, attr);
+             return;
+         }
+         int X = x + offsetX;
+         int Y = y + offsetY;
+         // System.err.printf("putCharXY: %d, %d, %c\n", X, Y, ch);
+         if ((X >= 0) && (X < width) && (Y >= 0) && (Y < height)) {
+             // Do not put control characters on the display
+             assert (ch >= 0x20);
+             assert (ch != 0x7F);
+             logical[X][Y].setTo(attr);
+             logical[X][Y].setChar(ch);
+             // If this happens to be the cursor position, make the position
+             // dirty.
+             if ((cursorX == X) && (cursorY == Y)) {
+                 physical[cursorX][cursorY].unset();
+                 unsetImageRow(cursorY);
+             }
+         }
+     }
+     /**
+      * Render one character without changing the underlying attributes.
+      *
+      * @param x column coordinate.  0 is the left-most column.
+      * @param y row coordinate.  0 is the top-most row.
+      * @param ch character to draw
+      */
+     public final void putCharXY(final int x, final int y, final int ch) {
+         if ((x < clipLeft)
+             || (x >= clipRight)
+             || (y < clipTop)
+             || (y >= clipBottom)
+         ) {
+             return;
+         }
+         if (StringUtils.width(ch) == 2) {
+             putFullwidthCharXY(x, y, ch);
+             return;
+         }
+         int X = x + offsetX;
+         int Y = y + offsetY;
+         // System.err.printf("putCharXY: %d, %d, %c\n", X, Y, ch);
+         if ((X >= 0) && (X < width) && (Y >= 0) && (Y < height)) {
+             logical[X][Y].setChar(ch);
+             // If this happens to be the cursor position, make the position
+             // dirty.
+             if ((cursorX == X) && (cursorY == Y)) {
+                 physical[cursorX][cursorY].unset();
+                 unsetImageRow(cursorY);
+             }
+         }
+     }
+     /**
+      * Render a string.  Does not wrap if the string exceeds the line.
+      *
+      * @param x column coordinate.  0 is the left-most column.
+      * @param y row coordinate.  0 is the top-most row.
+      * @param str string to draw
+      * @param attr attributes to use (bold, foreColor, backColor)
+      */
+     public final void putStringXY(final int x, final int y, final String str,
+         final CellAttributes attr) {
+         int i = x;
+         for (int j = 0; j < str.length();) {
+             int ch = str.codePointAt(j);
+             j += Character.charCount(ch);
+             putCharXY(i, y, ch, attr);
+             i += StringUtils.width(ch);
+             if (i == width) {
+                 break;
+             }
+         }
+     }
+     /**
+      * Render a string without changing the underlying attribute.  Does not
+      * wrap if the string exceeds the line.
+      *
+      * @param x column coordinate.  0 is the left-most column.
+      * @param y row coordinate.  0 is the top-most row.
+      * @param str string to draw
+      */
+     public final void putStringXY(final int x, final int y, final String str) {
+         int i = x;
+         for (int j = 0; j < str.length();) {
+             int ch = str.codePointAt(j);
+             j += Character.charCount(ch);
+             putCharXY(i, y, ch);
+             i += StringUtils.width(ch);
+             if (i == width) {
+                 break;
+             }
+         }
+     }
+     /**
+      * Draw a vertical line from (x, y) to (x, y + n).
+      *
+      * @param x column coordinate.  0 is the left-most column.
+      * @param y row coordinate.  0 is the top-most row.
+      * @param n number of characters to draw
+      * @param ch character to draw
+      * @param attr attributes to use (bold, foreColor, backColor)
+      */
+     public final void vLineXY(final int x, final int y, final int n,
+         final int ch, final CellAttributes attr) {
+         for (int i = y; i < y + n; i++) {
+             putCharXY(x, i, ch, attr);
+         }
+     }
+     /**
+      * Draw a horizontal line from (x, y) to (x + n, y).
+      *
+      * @param x column coordinate.  0 is the left-most column.
+      * @param y row coordinate.  0 is the top-most row.
+      * @param n number of characters to draw
+      * @param ch character to draw
+      * @param attr attributes to use (bold, foreColor, backColor)
+      */
+     public final void hLineXY(final int x, final int y, final int n,
+         final int ch, final CellAttributes attr) {
+         for (int i = x; i < x + n; i++) {
+             putCharXY(i, y, ch, attr);
+         }
+     }
+     /**
+      * Change the width.  Everything on-screen will be destroyed and must be
+      * redrawn.
+      *
+      * @param width new screen width
+      */
+     public final synchronized void setWidth(final int width) {
+         reallocate(width, this.height);
+     }
+     /**
+      * Change the height.  Everything on-screen will be destroyed and must be
+      * redrawn.
+      *
+      * @param height new screen height
+      */
+     public final synchronized void setHeight(final int height) {
+         reallocate(this.width, height);
+     }
+     /**
+      * Change the width and height.  Everything on-screen will be destroyed
+      * and must be redrawn.
+      *
+      * @param width new screen width
+      * @param height new screen height
+      */
+     public final void setDimensions(final int width, final int height) {
+         reallocate(width, height);
+         resizeToScreen();
+     }
+     /**
+      * Resize the physical screen to match the logical screen dimensions.
+      */
+     public void resizeToScreen() {
+         // Subclasses are expected to override this.
+     }
+     /**
+      * Get the height.
+      *
+      * @return current screen height
+      */
+     public final synchronized int getHeight() {
+         return this.height;
+     }
+     /**
+      * Get the width.
+      *
+      * @return current screen width
+      */
+     public final synchronized int getWidth() {
+         return this.width;
+     }
+     /**
+      * Reset screen to not-bold, white-on-black.  Also flushes the offset and
+      * clip variables.
+      */
+     public final synchronized void reset() {
+         for (int row = 0; row < height; row++) {
+             for (int col = 0; col < width; col++) {
+                 logical[col][row].reset();
+             }
+         }
+         resetClipping();
+     }
+     /**
+      * Flush the offset and clip variables.
+      */
+     public final void resetClipping() {
+         offsetX    = 0;
+         offsetY    = 0;
+         clipLeft   = 0;
+         clipTop    = 0;
+         clipRight  = width;
+         clipBottom = height;
+     }
+     /**
+      * Clear the logical screen.
+      */
+     public final void clear() {
+         reset();
+     }
+     /**
+      * Draw a box with a border and empty background.
+      *
+      * @param left left column of box.  0 is the left-most column.
+      * @param top top row of the box.  0 is the top-most row.
+      * @param right right column of box
+      * @param bottom bottom row of the box
+      * @param border attributes to use for the border
+      * @param background attributes to use for the background
+      */
+     public final void drawBox(final int left, final int top,
+         final int right, final int bottom,
+         final CellAttributes border, final CellAttributes background) {
+         drawBox(left, top, right, bottom, border, background, 1, false);
+     }
+     /**
+      * Draw a box with a border and empty background.
+      *
+      * @param left left column of box.  0 is the left-most column.
+      * @param top top row of the box.  0 is the top-most row.
+      * @param right right column of box
+      * @param bottom bottom row of the box
+      * @param border attributes to use for the border
+      * @param background attributes to use for the background
+      * @param borderType if 1, draw a single-line border; if 2, draw a
+      * double-line border; if 3, draw double-line top/bottom edges and
+      * single-line left/right edges (like Qmodem)
+      * @param shadow if true, draw a "shadow" on the box
+      */
+     public final void drawBox(final int left, final int top,
+         final int right, final int bottom,
+         final CellAttributes border, final CellAttributes background,
+         final int borderType, final boolean shadow) {
+         int boxWidth = right - left;
+         int boxHeight = bottom - top;
+         char cTopLeft;
+         char cTopRight;
+         char cBottomLeft;
+         char cBottomRight;
+         char cHSide;
+         char cVSide;
+         switch (borderType) {
+         case 1:
+             cTopLeft = GraphicsChars.ULCORNER;
+             cTopRight = GraphicsChars.URCORNER;
+             cBottomLeft = GraphicsChars.LLCORNER;
+             cBottomRight = GraphicsChars.LRCORNER;
+             cHSide = GraphicsChars.SINGLE_BAR;
+             cVSide = GraphicsChars.WINDOW_SIDE;
+             break;
+         case 2:
+             cTopLeft = GraphicsChars.WINDOW_LEFT_TOP_DOUBLE;
+             cTopRight = GraphicsChars.WINDOW_RIGHT_TOP_DOUBLE;
+             cBottomLeft = GraphicsChars.WINDOW_LEFT_BOTTOM_DOUBLE;
+             cBottomRight = GraphicsChars.WINDOW_RIGHT_BOTTOM_DOUBLE;
+             cHSide = GraphicsChars.DOUBLE_BAR;
+             cVSide = GraphicsChars.WINDOW_SIDE_DOUBLE;
+             break;
+         case 3:
+             cTopLeft = GraphicsChars.WINDOW_LEFT_TOP;
+             cTopRight = GraphicsChars.WINDOW_RIGHT_TOP;
+             cBottomLeft = GraphicsChars.WINDOW_LEFT_BOTTOM;
+             cBottomRight = GraphicsChars.WINDOW_RIGHT_BOTTOM;
+             cHSide = GraphicsChars.WINDOW_TOP;
+             cVSide = GraphicsChars.WINDOW_SIDE;
+             break;
+         default:
+             throw new IllegalArgumentException("Invalid border type: "
+                 + borderType);
+         }
+         // Place the corner characters
+         putCharXY(left, top, cTopLeft, border);
+         putCharXY(left + boxWidth - 1, top, cTopRight, border);
+         putCharXY(left, top + boxHeight - 1, cBottomLeft, border);
+         putCharXY(left + boxWidth - 1, top + boxHeight - 1, cBottomRight,
+             border);
+         // Draw the box lines
+         hLineXY(left + 1, top, boxWidth - 2, cHSide, border);
+         vLineXY(left, top + 1, boxHeight - 2, cVSide, border);
+         hLineXY(left + 1, top + boxHeight - 1, boxWidth - 2, cHSide, border);
+         vLineXY(left + boxWidth - 1, top + 1, boxHeight - 2, cVSide, border);
+         // Fill in the interior background
+         for (int i = 1; i < boxHeight - 1; i++) {
+             hLineXY(1 + left, i + top, boxWidth - 2, ' ', background);
+         }
+         if (shadow) {
+             // Draw a shadow
+             drawBoxShadow(left, top, right, bottom);
+         }
+     }
+     /**
+      * Draw a box shadow.
+      *
+      * @param left left column of box.  0 is the left-most column.
+      * @param top top row of the box.  0 is the top-most row.
+      * @param right right column of box
+      * @param bottom bottom row of the box
+      */
+     public final void drawBoxShadow(final int left, final int top,
+         final int right, final int bottom) {
+         int boxTop = top;
+         int boxLeft = left;
+         int boxWidth = right - left;
+         int boxHeight = bottom - top;
+         CellAttributes shadowAttr = new CellAttributes();
+         // Shadows do not honor clipping but they DO honor offset.
+         int oldClipRight = clipRight;
+         int oldClipBottom = clipBottom;
+         // When offsetX or offsetY go negative, we need to increase the clip
+         // bounds.
+         clipRight = width - offsetX;
+         clipBottom = height - offsetY;
+         for (int i = 0; i < boxHeight; i++) {
+             Cell cell = getCharXY(offsetX + boxLeft + boxWidth,
+                 offsetY + boxTop + 1 + i);
+             if (cell.getWidth() == Cell.Width.SINGLE) {
+                 putAttrXY(boxLeft + boxWidth, boxTop + 1 + i, shadowAttr);
+             } else {
+                 putCharXY(boxLeft + boxWidth, boxTop + 1 + i, ' ', shadowAttr);
+             }
+             cell = getCharXY(offsetX + boxLeft + boxWidth + 1,
+                 offsetY + boxTop + 1 + i);
+             if (cell.getWidth() == Cell.Width.SINGLE) {
+                 putAttrXY(boxLeft + boxWidth + 1, boxTop + 1 + i, shadowAttr);
+             } else {
+                 putCharXY(boxLeft + boxWidth + 1, boxTop + 1 + i, ' ',
+                     shadowAttr);
+             }
+         }
+         for (int i = 0; i < boxWidth; i++) {
+             Cell cell = getCharXY(offsetX + boxLeft + 2 + i,
+                 offsetY + boxTop + boxHeight);
+             if (cell.getWidth() == Cell.Width.SINGLE) {
+                 putAttrXY(boxLeft + 2 + i, boxTop + boxHeight, shadowAttr);
+             } else {
+                 putCharXY(boxLeft + 2 + i, boxTop + boxHeight, ' ', shadowAttr);
+             }
+         }
+         clipRight = oldClipRight;
+         clipBottom = oldClipBottom;
+     }
+     /**
+      * Default implementation does nothing.
+      */
+     public void flushPhysical() {}
+     /**
+      * Put the cursor at (x,y).
+      *
+      * @param visible if true, the cursor should be visible
+      * @param x column coordinate to put the cursor on
+      * @param y row coordinate to put the cursor on
+      */
+     public void putCursor(final boolean visible, final int x, final int y) {
+         if ((cursorY >= 0)
+             && (cursorX >= 0)
+             && (cursorY <= height - 1)
+             && (cursorX <= width - 1)
+         ) {
+             // Make the current cursor position dirty
+             physical[cursorX][cursorY].unset();
+             unsetImageRow(cursorY);
+         }
+         cursorVisible = visible;
+         cursorX = x;
+         cursorY = y;
+     }
+     /**
+      * Hide the cursor.
+      */
+     public final void hideCursor() {
+         cursorVisible = false;
+     }
+     /**
+      * Get the cursor visibility.
+      *
+      * @return true if the cursor is visible
+      */
+     public boolean isCursorVisible() {
+         return cursorVisible;
+     }
+     /**
+      * Get the cursor X position.
+      *
+      * @return the cursor x column position
+      */
+     public int getCursorX() {
+         return cursorX;
+     }
+     /**
+      * Get the cursor Y position.
+      *
+      * @return the cursor y row position
+      */
+     public int getCursorY() {
+         return cursorY;
+     }
+     /**
+      * Set the window title.  Default implementation does nothing.
+      *
+      * @param title the new title
+      */
+     public void setTitle(final String title) {}
+     // ------------------------------------------------------------------------
+     // LogicalScreen ----------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Reallocate screen buffers.
+      *
+      * @param width new width
+      * @param height new height
+      */
+     private synchronized void reallocate(final int width, final int height) {
+         if (logical != null) {
+             for (int row = 0; row < this.height; row++) {
+                 for (int col = 0; col < this.width; col++) {
+                     logical[col][row] = null;
+                 }
+             }
+             logical = null;
+         }
+         logical = new Cell[width][height];
+         if (physical != null) {
+             for (int row = 0; row < this.height; row++) {
+                 for (int col = 0; col < this.width; col++) {
+                     physical[col][row] = null;
+                 }
+             }
+             physical = null;
+         }
+         physical = new Cell[width][height];
+         for (int row = 0; row < height; row++) {
+             for (int col = 0; col < width; col++) {
+                 physical[col][row] = new Cell();
+                 logical[col][row] = new Cell();
+             }
+         }
+         this.width = width;
+         this.height = height;
+         clipLeft = 0;
+         clipTop = 0;
+         clipRight = width;
+         clipBottom = height;
+         reallyCleared = true;
+     }
+     /**
+      * Clear the physical screen.
+      */
+     public final void clearPhysical() {
+         for (int row = 0; row < height; row++) {
+             for (int col = 0; col < width; col++) {
+                 physical[col][row].unset();
+             }
+         }
+     }
+     /**
+      * Unset every image cell on one row of the physical screen, forcing
+      * images on that row to be redrawn.
+      *
+      * @param y row coordinate.  0 is the top-most row.
+      */
+     public final void unsetImageRow(final int y) {
+         if ((y < 0) || (y >= height)) {
+             return;
+         }
+         for (int x = 0; x < width; x++) {
+             if (logical[x][y].isImage()) {
+                 physical[x][y].unset();
+             }
+         }
+     }
+     /**
+      * Render one fullwidth cell.
+      *
+      * @param x column coordinate.  0 is the left-most column.
+      * @param y row coordinate.  0 is the top-most row.
+      * @param cell the cell to draw
+      */
+     public final void putFullwidthCharXY(final int x, final int y,
+         final Cell cell) {
+         int cellWidth = getTextWidth();
+         int cellHeight = getTextHeight();
+         if (lastTextHeight != cellHeight) {
+             glyphMaker = GlyphMaker.getInstance(cellHeight);
+             lastTextHeight = cellHeight;
+         }
+         BufferedImage image = glyphMaker.getImage(cell, cellWidth * 2,
+             cellHeight);
+         BufferedImage leftImage = image.getSubimage(0, 0, cellWidth,
+             cellHeight);
+         BufferedImage rightImage = image.getSubimage(cellWidth, 0, cellWidth,
+             cellHeight);
+         Cell left = new Cell(cell);
+         left.setImage(leftImage);
+         left.setWidth(Cell.Width.LEFT);
+         putCharXY(x, y, left);
+         Cell right = new Cell(cell);
+         right.setImage(rightImage);
+         right.setWidth(Cell.Width.RIGHT);
+         putCharXY(x + 1, y, right);
+     }
+     /**
+      * Render one fullwidth character with attributes.
+      *
+      * @param x column coordinate.  0 is the left-most column.
+      * @param y row coordinate.  0 is the top-most row.
+      * @param ch character to draw
+      * @param attr attributes to use (bold, foreColor, backColor)
+      */
+     public final void putFullwidthCharXY(final int x, final int y,
+         final int ch, final CellAttributes attr) {
+         Cell cell = new Cell(ch, attr);
+         putFullwidthCharXY(x, y, cell);
+     }
+     /**
+      * Render one fullwidth character with attributes.
+      *
+      * @param x column coordinate.  0 is the left-most column.
+      * @param y row coordinate.  0 is the top-most row.
+      * @param ch character to draw
+      */
+     public final void putFullwidthCharXY(final int x, final int y,
+         final int ch) {
+         Cell cell = new Cell(ch);
+         cell.setAttr(getAttrXY(x, y));
+         putFullwidthCharXY(x, y, cell);
+     }
+ }
index 0000000000000000000000000000000000000000,d01b9442c0526cc9ce2a67af0be5e8f1a2a514c7..d01b9442c0526cc9ce2a67af0be5e8f1a2a514c7
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,254 +1,254 @@@
+ /*
+  * Jexer - Java Text User Interface
+  *
+  * The MIT License (MIT)
+  *
+  * 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"),
+  * to deal in the Software without restriction, including without limitation
+  * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+  * and/or sell copies of the Software, and to permit persons to whom the
+  * Software is furnished to do so, subject to the following conditions:
+  *
+  * The above copyright notice and this permission notice shall be included in
+  * all copies or substantial portions of the Software.
+  *
+  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+  * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+  * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+  * DEALINGS IN THE SOFTWARE.
+  *
+  * @author Kevin Lamonte [kevin.lamonte@gmail.com]
+  * @version 1
+  */
+ package jexer.backend;
+ import java.util.ArrayList;
+ import java.util.List;
+ import jexer.event.TCommandEvent;
+ import jexer.event.TInputEvent;
+ import static jexer.TCommand.*;
+ /**
+  * MultiBackend mirrors its I/O to several backends.
+  */
+ public class MultiBackend implements Backend {
+     // ------------------------------------------------------------------------
+     // Variables --------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * The screen to use.
+      */
+     private MultiScreen multiScreen;
+     /**
+      * The list of backends to use.
+      */
+     private List<Backend> backends = new ArrayList<Backend>();
+     /**
+      * The SessionInfo to return.
+      */
+     private SessionInfo sessionInfo;
+     // ------------------------------------------------------------------------
+     // Constructors -----------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Public constructor requires one backend.  Note that this backend's
+      * screen will be replaced with a MultiScreen.
+      *
+      * @param backend the backend to add
+      */
+     public MultiBackend(final Backend backend) {
+         backends.add(backend);
+         if (backend instanceof TWindowBackend) {
+             multiScreen = new MultiScreen(((TWindowBackend) backend).getOtherScreen());
+         } else {
+             multiScreen = new MultiScreen(backend.getScreen());
+         }
+         if (backend instanceof GenericBackend) {
+             ((GenericBackend) backend).abortOnDisconnect = false;
+         }
+         sessionInfo = backend.getSessionInfo();
+     }
+     // ------------------------------------------------------------------------
+     // Backend ----------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Getter for sessionInfo.
+      *
+      * @return the SessionInfo
+      */
+     public SessionInfo getSessionInfo() {
+         return sessionInfo;
+     }
+     /**
+      * Getter for screen.
+      *
+      * @return the Screen
+      */
+     public Screen getScreen() {
+         return multiScreen;
+     }
+     /**
+      * Subclasses must provide an implementation that syncs the logical
+      * screen to the physical device.
+      */
+     public void flushScreen() {
+         for (Backend backend: backends) {
+             backend.flushScreen();
+         }
+     }
+     /**
+      * Check if there are events in the queue.
+      *
+      * @return if true, getEvents() has something to return to the application
+      */
+     public boolean hasEvents() {
+         if (backends.size() == 0) {
+             return true;
+         }
+         for (Backend backend: backends) {
+             if (backend.hasEvents()) {
+                 return true;
+             }
+         }
+         return false;
+     }
+     /**
+      * Subclasses must provide an implementation to get keyboard, mouse, and
+      * screen resize events.
+      *
+      * @param queue list to append new events to
+      */
+     public void getEvents(List<TInputEvent> queue) {
+         List<Backend> backendsToRemove = null;
+         for (Backend backend: backends) {
+             if (backend.hasEvents()) {
+                 backend.getEvents(queue);
+                 // This default backend assumes a single user, and if that
+                 // user becomes disconnected we should terminate the
+                 // application.
+                 if (queue.size() > 0) {
+                     TInputEvent event = queue.get(queue.size() - 1);
+                     if (event instanceof TCommandEvent) {
+                         TCommandEvent command = (TCommandEvent) event;
+                         if (command.equals(cmBackendDisconnect)) {
+                             if (backendsToRemove == null) {
+                                 backendsToRemove = new ArrayList<Backend>();
+                             }
+                             backendsToRemove.add(backend);
+                         }
+                     }
+                 }
+             }
+         }
+         if (backendsToRemove != null) {
+             for (Backend backend: backendsToRemove) {
+                 multiScreen.removeScreen(backend.getScreen());
+                 backends.remove(backend);
+                 backend.shutdown();
+             }
+         }
+         if (backends.size() == 0) {
+             queue.add(new TCommandEvent(cmAbort));
+         }
+     }
+     /**
+      * Subclasses must provide an implementation that closes sockets,
+      * restores console, etc.
+      */
+     public void shutdown() {
+         for (Backend backend: backends) {
+             backend.shutdown();
+         }
+     }
+     /**
+      * Subclasses must provide an implementation that sets the window title.
+      *
+      * @param title the new title
+      */
+     public void setTitle(final String title) {
+         for (Backend backend: backends) {
+             backend.setTitle(title);
+         }
+     }
+     /**
+      * 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) {
+         for (Backend backend: backends) {
+             backend.setListener(listener);
+         }
+     }
+     /**
+      * Reload backend options from System properties.
+      */
+     public void reloadOptions() {
+         for (Backend backend: backends) {
+             backend.reloadOptions();
+         }
+     }
+     // ------------------------------------------------------------------------
+     // MultiBackend -----------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Add a backend to the list.
+      *
+      * @param backend the backend to add
+      */
+     public void addBackend(final Backend backend) {
+         backends.add(backend);
+         if (backend instanceof TWindowBackend) {
+             multiScreen.addScreen(((TWindowBackend) backend).getOtherScreen());
+         } else {
+             multiScreen.addScreen(backend.getScreen());
+         }
+         if (backend instanceof GenericBackend) {
+             ((GenericBackend) backend).abortOnDisconnect = false;
+         }
+     }
+     /**
+      * Remove a backend from the list.
+      *
+      * @param backend the backend to remove
+      */
+     public void removeBackend(final Backend backend) {
+         if (backends.size() > 1) {
+             if (backend instanceof TWindowBackend) {
+                 multiScreen.removeScreen(((TWindowBackend) backend).getOtherScreen());
+             } else {
+                 multiScreen.removeScreen(backend.getScreen());
+             }
+             backends.remove(backend);
+         }
+     }
+ }
index 0000000000000000000000000000000000000000,9d66b69342896a50c6d8683d6cc4c463db4491c2..9d66b69342896a50c6d8683d6cc4c463db4491c2
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,673 +1,673 @@@
+ /*
+  * Jexer - Java Text User Interface
+  *
+  * The MIT License (MIT)
+  *
+  * 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"),
+  * to deal in the Software without restriction, including without limitation
+  * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+  * and/or sell copies of the Software, and to permit persons to whom the
+  * Software is furnished to do so, subject to the following conditions:
+  *
+  * The above copyright notice and this permission notice shall be included in
+  * all copies or substantial portions of the Software.
+  *
+  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+  * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+  * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+  * DEALINGS IN THE SOFTWARE.
+  *
+  * @author Kevin Lamonte [kevin.lamonte@gmail.com]
+  * @version 1
+  */
+ package jexer.backend;
+ import java.util.ArrayList;
+ import java.util.List;
+ import jexer.bits.Cell;
+ import jexer.bits.CellAttributes;
+ /**
+  * MultiScreen mirrors its I/O to several screens.
+  */
+ public class MultiScreen implements Screen {
+     // ------------------------------------------------------------------------
+     // Variables --------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * The list of screens to use.
+      */
+     private List<Screen> screens = new ArrayList<Screen>();
+     // ------------------------------------------------------------------------
+     // Constructors -----------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Public constructor requires one screen.
+      *
+      * @param screen the screen to add
+      */
+     public MultiScreen(final Screen screen) {
+         screens.add(screen);
+     }
+     // ------------------------------------------------------------------------
+     // Screen -----------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Set drawing offset for x.
+      *
+      * @param offsetX new drawing offset
+      */
+     public void setOffsetX(final int offsetX) {
+         for (Screen screen: screens) {
+             screen.setOffsetX(offsetX);
+         }
+     }
+     /**
+      * Set drawing offset for y.
+      *
+      * @param offsetY new drawing offset
+      */
+     public void setOffsetY(final int offsetY) {
+         for (Screen screen: screens) {
+             screen.setOffsetY(offsetY);
+         }
+     }
+     /**
+      * Get right drawing clipping boundary.
+      *
+      * @return drawing boundary
+      */
+     public int getClipRight() {
+         return screens.get(0).getClipRight();
+     }
+     /**
+      * Set right drawing clipping boundary.
+      *
+      * @param clipRight new boundary
+      */
+     public void setClipRight(final int clipRight) {
+         for (Screen screen: screens) {
+             screen.setClipRight(clipRight);
+         }
+     }
+     /**
+      * Get bottom drawing clipping boundary.
+      *
+      * @return drawing boundary
+      */
+     public int getClipBottom() {
+         return screens.get(0).getClipBottom();
+     }
+     /**
+      * Set bottom drawing clipping boundary.
+      *
+      * @param clipBottom new boundary
+      */
+     public void setClipBottom(final int clipBottom) {
+         for (Screen screen: screens) {
+             screen.setClipBottom(clipBottom);
+         }
+     }
+     /**
+      * Get left drawing clipping boundary.
+      *
+      * @return drawing boundary
+      */
+     public int getClipLeft() {
+         return screens.get(0).getClipLeft();
+     }
+     /**
+      * Set left drawing clipping boundary.
+      *
+      * @param clipLeft new boundary
+      */
+     public void setClipLeft(final int clipLeft) {
+         for (Screen screen: screens) {
+             screen.setClipLeft(clipLeft);
+         }
+     }
+     /**
+      * Get top drawing clipping boundary.
+      *
+      * @return drawing boundary
+      */
+     public int getClipTop() {
+         return screens.get(0).getClipTop();
+     }
+     /**
+      * Set top drawing clipping boundary.
+      *
+      * @param clipTop new boundary
+      */
+     public void setClipTop(final int clipTop) {
+         for (Screen screen: screens) {
+             screen.setClipTop(clipTop);
+         }
+     }
+     /**
+      * Get dirty flag.
+      *
+      * @return if true, the logical screen is not in sync with the physical
+      * screen
+      */
+     public boolean isDirty() {
+         for (Screen screen: screens) {
+             if (screen.isDirty()) {
+                 return true;
+             }
+         }
+         return false;
+     }
+     /**
+      * Get the attributes at one location.
+      *
+      * @param x column coordinate.  0 is the left-most column.
+      * @param y row coordinate.  0 is the top-most row.
+      * @return attributes at (x, y)
+      */
+     public CellAttributes getAttrXY(final int x, final int y) {
+         return screens.get(0).getAttrXY(x, y);
+     }
+     /**
+      * Get the cell at one location.
+      *
+      * @param x column coordinate.  0 is the left-most column.
+      * @param y row coordinate.  0 is the top-most row.
+      * @return the character + attributes
+      */
+     public Cell getCharXY(final int x, final int y) {
+         return screens.get(0).getCharXY(x, y);
+     }
+     /**
+      * Set the attributes at one location.
+      *
+      * @param x column coordinate.  0 is the left-most column.
+      * @param y row coordinate.  0 is the top-most row.
+      * @param attr attributes to use (bold, foreColor, backColor)
+      */
+     public void putAttrXY(final int x, final int y,
+         final CellAttributes attr) {
+         for (Screen screen: screens) {
+             screen.putAttrXY(x, y, attr);
+         }
+     }
+     /**
+      * Set the attributes at one location.
+      *
+      * @param x column coordinate.  0 is the left-most column.
+      * @param y row coordinate.  0 is the top-most row.
+      * @param attr attributes to use (bold, foreColor, backColor)
+      * @param clip if true, honor clipping/offset
+      */
+     public void putAttrXY(final int x, final int y,
+         final CellAttributes attr, final boolean clip) {
+         for (Screen screen: screens) {
+             screen.putAttrXY(x, y, attr, clip);
+         }
+     }
+     /**
+      * Fill the entire screen with one character with attributes.
+      *
+      * @param ch character to draw
+      * @param attr attributes to use (bold, foreColor, backColor)
+      */
+     public void putAll(final int ch, final CellAttributes attr) {
+         for (Screen screen: screens) {
+             screen.putAll(ch, attr);
+         }
+     }
+     /**
+      * Render one character with attributes.
+      *
+      * @param x column coordinate.  0 is the left-most column.
+      * @param y row coordinate.  0 is the top-most row.
+      * @param ch character + attributes to draw
+      */
+     public void putCharXY(final int x, final int y, final Cell ch) {
+         for (Screen screen: screens) {
+             screen.putCharXY(x, y, ch);
+         }
+     }
+     /**
+      * Render one character with attributes.
+      *
+      * @param x column coordinate.  0 is the left-most column.
+      * @param y row coordinate.  0 is the top-most row.
+      * @param ch character to draw
+      * @param attr attributes to use (bold, foreColor, backColor)
+      */
+     public void putCharXY(final int x, final int y, final int ch,
+         final CellAttributes attr) {
+         for (Screen screen: screens) {
+             screen.putCharXY(x, y, ch, attr);
+         }
+     }
+     /**
+      * Render one character without changing the underlying attributes.
+      *
+      * @param x column coordinate.  0 is the left-most column.
+      * @param y row coordinate.  0 is the top-most row.
+      * @param ch character to draw
+      */
+     public void putCharXY(final int x, final int y, final int ch) {
+         for (Screen screen: screens) {
+             screen.putCharXY(x, y, ch);
+         }
+     }
+     /**
+      * Render a string.  Does not wrap if the string exceeds the line.
+      *
+      * @param x column coordinate.  0 is the left-most column.
+      * @param y row coordinate.  0 is the top-most row.
+      * @param str string to draw
+      * @param attr attributes to use (bold, foreColor, backColor)
+      */
+     public void putStringXY(final int x, final int y, final String str,
+         final CellAttributes attr) {
+         for (Screen screen: screens) {
+             screen.putStringXY(x, y, str, attr);
+         }
+     }
+     /**
+      * Render a string without changing the underlying attribute.  Does not
+      * wrap if the string exceeds the line.
+      *
+      * @param x column coordinate.  0 is the left-most column.
+      * @param y row coordinate.  0 is the top-most row.
+      * @param str string to draw
+      */
+     public void putStringXY(final int x, final int y, final String str) {
+         for (Screen screen: screens) {
+             screen.putStringXY(x, y, str);
+         }
+     }
+     /**
+      * Draw a vertical line from (x, y) to (x, y + n).
+      *
+      * @param x column coordinate.  0 is the left-most column.
+      * @param y row coordinate.  0 is the top-most row.
+      * @param n number of characters to draw
+      * @param ch character to draw
+      * @param attr attributes to use (bold, foreColor, backColor)
+      */
+     public void vLineXY(final int x, final int y, final int n,
+         final int ch, final CellAttributes attr) {
+         for (Screen screen: screens) {
+             screen.vLineXY(x, y, n, ch, attr);
+         }
+     }
+     /**
+      * Draw a horizontal line from (x, y) to (x + n, y).
+      *
+      * @param x column coordinate.  0 is the left-most column.
+      * @param y row coordinate.  0 is the top-most row.
+      * @param n number of characters to draw
+      * @param ch character to draw
+      * @param attr attributes to use (bold, foreColor, backColor)
+      */
+     public void hLineXY(final int x, final int y, final int n,
+         final int ch, final CellAttributes attr) {
+         for (Screen screen: screens) {
+             screen.hLineXY(x, y, n, ch, attr);
+         }
+     }
+     /**
+      * Change the width.  Everything on-screen will be destroyed and must be
+      * redrawn.
+      *
+      * @param width new screen width
+      */
+     public void setWidth(final int width) {
+         for (Screen screen: screens) {
+             screen.setWidth(width);
+         }
+     }
+     /**
+      * Change the height.  Everything on-screen will be destroyed and must be
+      * redrawn.
+      *
+      * @param height new screen height
+      */
+     public void setHeight(final int height) {
+         for (Screen screen: screens) {
+             screen.setHeight(height);
+         }
+     }
+     /**
+      * Change the width and height.  Everything on-screen will be destroyed
+      * and must be redrawn.
+      *
+      * @param width new screen width
+      * @param height new screen height
+      */
+     public void setDimensions(final int width, final int height) {
+         for (Screen screen: screens) {
+             // Do not blindly call setDimension() on every screen.  Instead
+             // call it only on those screens that do not already have the
+             // requested dimension.  With this very small check, we have the
+             // ability for ANY screen in the MultiBackend to resize ALL of
+             // the screens.
+             if ((screen.getWidth() != width)
+                 || (screen.getHeight() != height)
+             ) {
+                 screen.setDimensions(width, height);
+             } else {
+                 // The screen that didn't change is probably the one that
+                 // prompted the resize.  Force it to repaint.
+                 screen.clearPhysical();
+             }
+         }
+     }
+     /**
+      * Get the height.
+      *
+      * @return current screen height
+      */
+     public int getHeight() {
+         // Return the smallest height of the screens.
+         int height = screens.get(0).getHeight();
+         for (Screen screen: screens) {
+             if (screen.getHeight() < height) {
+                 height = screen.getHeight();
+             }
+         }
+         return height;
+     }
+     /**
+      * Get the width.
+      *
+      * @return current screen width
+      */
+     public int getWidth() {
+         // Return the smallest width of the screens.
+         int width = screens.get(0).getWidth();
+         for (Screen screen: screens) {
+             if (screen.getWidth() < width) {
+                 width = screen.getWidth();
+             }
+         }
+         return width;
+     }
+     /**
+      * Reset screen to not-bold, white-on-black.  Also flushes the offset and
+      * clip variables.
+      */
+     public void reset() {
+         for (Screen screen: screens) {
+             screen.reset();
+         }
+     }
+     /**
+      * Flush the offset and clip variables.
+      */
+     public void resetClipping() {
+         for (Screen screen: screens) {
+             screen.resetClipping();
+         }
+     }
+     /**
+      * Clear the logical screen.
+      */
+     public void clear() {
+         for (Screen screen: screens) {
+             screen.clear();
+         }
+     }
+     /**
+      * Draw a box with a border and empty background.
+      *
+      * @param left left column of box.  0 is the left-most row.
+      * @param top top row of the box.  0 is the top-most row.
+      * @param right right column of box
+      * @param bottom bottom row of the box
+      * @param border attributes to use for the border
+      * @param background attributes to use for the background
+      */
+     public void drawBox(final int left, final int top,
+         final int right, final int bottom,
+         final CellAttributes border, final CellAttributes background) {
+         for (Screen screen: screens) {
+             screen.drawBox(left, top, right, bottom, border, background);
+         }
+     }
+     /**
+      * Draw a box with a border and empty background.
+      *
+      * @param left left column of box.  0 is the left-most row.
+      * @param top top row of the box.  0 is the top-most row.
+      * @param right right column of box
+      * @param bottom bottom row of the box
+      * @param border attributes to use for the border
+      * @param background attributes to use for the background
+      * @param borderType if 1, draw a single-line border; if 2, draw a
+      * double-line border; if 3, draw double-line top/bottom edges and
+      * single-line left/right edges (like Qmodem)
+      * @param shadow if true, draw a "shadow" on the box
+      */
+     public void drawBox(final int left, final int top,
+         final int right, final int bottom,
+         final CellAttributes border, final CellAttributes background,
+         final int borderType, final boolean shadow) {
+         for (Screen screen: screens) {
+             screen.drawBox(left, top, right, bottom, border, background,
+                 borderType, shadow);
+         }
+     }
+     /**
+      * Draw a box shadow.
+      *
+      * @param left left column of box.  0 is the left-most row.
+      * @param top top row of the box.  0 is the top-most row.
+      * @param right right column of box
+      * @param bottom bottom row of the box
+      */
+     public void drawBoxShadow(final int left, final int top,
+         final int right, final int bottom) {
+         for (Screen screen: screens) {
+             screen.drawBoxShadow(left, top, right, bottom);
+         }
+     }
+     /**
+      * Clear the physical screen.
+      */
+     public void clearPhysical() {
+         for (Screen screen: screens) {
+             screen.clearPhysical();
+         }
+     }
+     /**
+      * Unset every image cell on one row of the physical screen, forcing
+      * images on that row to be redrawn.
+      *
+      * @param y row coordinate.  0 is the top-most row.
+      */
+     public final void unsetImageRow(final int y) {
+         for (Screen screen: screens) {
+             screen.unsetImageRow(y);
+         }
+     }
+     /**
+      * Classes must provide an implementation to push the logical screen to
+      * the physical device.
+      */
+     public void flushPhysical() {
+         for (Screen screen: screens) {
+             screen.flushPhysical();
+         }
+     }
+     /**
+      * Put the cursor at (x,y).
+      *
+      * @param visible if true, the cursor should be visible
+      * @param x column coordinate to put the cursor on
+      * @param y row coordinate to put the cursor on
+      */
+     public void putCursor(final boolean visible, final int x, final int y) {
+         for (Screen screen: screens) {
+             screen.putCursor(visible, x, y);
+         }
+     }
+     /**
+      * Hide the cursor.
+      */
+     public void hideCursor() {
+         for (Screen screen: screens) {
+             screen.hideCursor();
+         }
+     }
+     /**
+      * Get the cursor visibility.
+      *
+      * @return true if the cursor is visible
+      */
+     public boolean isCursorVisible() {
+         return screens.get(0).isCursorVisible();
+     }
+     /**
+      * Get the cursor X position.
+      *
+      * @return the cursor x column position
+      */
+     public int getCursorX() {
+         return screens.get(0).getCursorX();
+     }
+     /**
+      * Get the cursor Y position.
+      *
+      * @return the cursor y row position
+      */
+     public int getCursorY() {
+         return screens.get(0).getCursorY();
+     }
+     /**
+      * Set the window title.
+      *
+      * @param title the new title
+      */
+     public void setTitle(final String title) {
+         for (Screen screen: screens) {
+             screen.setTitle(title);
+         }
+     }
+     // ------------------------------------------------------------------------
+     // MultiScreen ------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Add a screen to the list.
+      *
+      * @param screen the screen to add
+      */
+     public void addScreen(final Screen screen) {
+         screens.add(screen);
+     }
+     /**
+      * Remove a screen from the list.
+      *
+      * @param screen the screen to remove
+      */
+     public void removeScreen(final Screen screen) {
+         if (screens.size() > 1) {
+             screens.remove(screen);
+         }
+     }
+     /**
+      * Get the width of a character cell in pixels.
+      *
+      * @return the width in pixels of a character cell
+      */
+     public int getTextWidth() {
+         int textWidth = 16;
+         for (Screen screen: screens) {
+             int newTextWidth = screen.getTextWidth();
+             if (newTextWidth < textWidth) {
+                 textWidth = newTextWidth;
+             }
+         }
+         return textWidth;
+     }
+     /**
+      * Get the height of a character cell in pixels.
+      *
+      * @return the height in pixels of a character cell
+      */
+     public int getTextHeight() {
+         int textHeight = 20;
+         for (Screen screen: screens) {
+             int newTextHeight = screen.getTextHeight();
+             if (newTextHeight < textHeight) {
+                 textHeight = newTextHeight;
+             }
+         }
+         return textHeight;
+     }
+ }
index 0000000000000000000000000000000000000000,2a71073176a6608b3a35d0740855e187c746d0e0..2a71073176a6608b3a35d0740855e187c746d0e0
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,412 +1,412 @@@
+ /*
+  * Jexer - Java Text User Interface
+  *
+  * The MIT License (MIT)
+  *
+  * 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"),
+  * to deal in the Software without restriction, including without limitation
+  * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+  * and/or sell copies of the Software, and to permit persons to whom the
+  * Software is furnished to do so, subject to the following conditions:
+  *
+  * The above copyright notice and this permission notice shall be included in
+  * all copies or substantial portions of the Software.
+  *
+  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+  * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+  * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+  * DEALINGS IN THE SOFTWARE.
+  *
+  * @author Kevin Lamonte [kevin.lamonte@gmail.com]
+  * @version 1
+  */
+ package jexer.backend;
+ import jexer.bits.Cell;
+ import jexer.bits.CellAttributes;
+ /**
+  * Drawing operations API.
+  */
+ public interface Screen {
+     /**
+      * Set drawing offset for x.
+      *
+      * @param offsetX new drawing offset
+      */
+     public void setOffsetX(final int offsetX);
+     /**
+      * Set drawing offset for y.
+      *
+      * @param offsetY new drawing offset
+      */
+     public void setOffsetY(final int offsetY);
+     /**
+      * Get right drawing clipping boundary.
+      *
+      * @return drawing boundary
+      */
+     public int getClipRight();
+     /**
+      * Set right drawing clipping boundary.
+      *
+      * @param clipRight new boundary
+      */
+     public void setClipRight(final int clipRight);
+     /**
+      * Get bottom drawing clipping boundary.
+      *
+      * @return drawing boundary
+      */
+     public int getClipBottom();
+     /**
+      * Set bottom drawing clipping boundary.
+      *
+      * @param clipBottom new boundary
+      */
+     public void setClipBottom(final int clipBottom);
+     /**
+      * Get left drawing clipping boundary.
+      *
+      * @return drawing boundary
+      */
+     public int getClipLeft();
+     /**
+      * Set left drawing clipping boundary.
+      *
+      * @param clipLeft new boundary
+      */
+     public void setClipLeft(final int clipLeft);
+     /**
+      * Get top drawing clipping boundary.
+      *
+      * @return drawing boundary
+      */
+     public int getClipTop();
+     /**
+      * Set top drawing clipping boundary.
+      *
+      * @param clipTop new boundary
+      */
+     public void setClipTop(final int clipTop);
+     /**
+      * Get dirty flag.
+      *
+      * @return if true, the logical screen is not in sync with the physical
+      * screen
+      */
+     public boolean isDirty();
+     /**
+      * Get the attributes at one location.
+      *
+      * @param x column coordinate.  0 is the left-most column.
+      * @param y row coordinate.  0 is the top-most row.
+      * @return attributes at (x, y)
+      */
+     public CellAttributes getAttrXY(final int x, final int y);
+     /**
+      * Get the cell at one location.
+      *
+      * @param x column coordinate.  0 is the left-most column.
+      * @param y row coordinate.  0 is the top-most row.
+      * @return the character + attributes
+      */
+     public Cell getCharXY(final int x, final int y);
+     /**
+      * Set the attributes at one location.
+      *
+      * @param x column coordinate.  0 is the left-most column.
+      * @param y row coordinate.  0 is the top-most row.
+      * @param attr attributes to use (bold, foreColor, backColor)
+      */
+     public void putAttrXY(final int x, final int y,
+         final CellAttributes attr);
+     /**
+      * Set the attributes at one location.
+      *
+      * @param x column coordinate.  0 is the left-most column.
+      * @param y row coordinate.  0 is the top-most row.
+      * @param attr attributes to use (bold, foreColor, backColor)
+      * @param clip if true, honor clipping/offset
+      */
+     public void putAttrXY(final int x, final int y,
+         final CellAttributes attr, final boolean clip);
+     /**
+      * Fill the entire screen with one character with attributes.
+      *
+      * @param ch character to draw
+      * @param attr attributes to use (bold, foreColor, backColor)
+      */
+     public void putAll(final int ch, final CellAttributes attr);
+     /**
+      * Render one character with attributes.
+      *
+      * @param x column coordinate.  0 is the left-most column.
+      * @param y row coordinate.  0 is the top-most row.
+      * @param ch character + attributes to draw
+      */
+     public void putCharXY(final int x, final int y, final Cell ch);
+     /**
+      * Render one character with attributes.
+      *
+      * @param x column coordinate.  0 is the left-most column.
+      * @param y row coordinate.  0 is the top-most row.
+      * @param ch character to draw
+      * @param attr attributes to use (bold, foreColor, backColor)
+      */
+     public void putCharXY(final int x, final int y, final int ch,
+         final CellAttributes attr);
+     /**
+      * Render one character without changing the underlying attributes.
+      *
+      * @param x column coordinate.  0 is the left-most column.
+      * @param y row coordinate.  0 is the top-most row.
+      * @param ch character to draw
+      */
+     public void putCharXY(final int x, final int y, final int ch);
+     /**
+      * Render a string.  Does not wrap if the string exceeds the line.
+      *
+      * @param x column coordinate.  0 is the left-most column.
+      * @param y row coordinate.  0 is the top-most row.
+      * @param str string to draw
+      * @param attr attributes to use (bold, foreColor, backColor)
+      */
+     public void putStringXY(final int x, final int y, final String str,
+         final CellAttributes attr);
+     /**
+      * Render a string without changing the underlying attribute.  Does not
+      * wrap if the string exceeds the line.
+      *
+      * @param x column coordinate.  0 is the left-most column.
+      * @param y row coordinate.  0 is the top-most row.
+      * @param str string to draw
+      */
+     public void putStringXY(final int x, final int y, final String str);
+     /**
+      * Draw a vertical line from (x, y) to (x, y + n).
+      *
+      * @param x column coordinate.  0 is the left-most column.
+      * @param y row coordinate.  0 is the top-most row.
+      * @param n number of characters to draw
+      * @param ch character to draw
+      * @param attr attributes to use (bold, foreColor, backColor)
+      */
+     public void vLineXY(final int x, final int y, final int n,
+         final int ch, final CellAttributes attr);
+     /**
+      * Draw a horizontal line from (x, y) to (x + n, y).
+      *
+      * @param x column coordinate.  0 is the left-most column.
+      * @param y row coordinate.  0 is the top-most row.
+      * @param n number of characters to draw
+      * @param ch character to draw
+      * @param attr attributes to use (bold, foreColor, backColor)
+      */
+     public void hLineXY(final int x, final int y, final int n,
+         final int ch, final CellAttributes attr);
+     /**
+      * Change the width.  Everything on-screen will be destroyed and must be
+      * redrawn.
+      *
+      * @param width new screen width
+      */
+     public void setWidth(final int width);
+     /**
+      * Change the height.  Everything on-screen will be destroyed and must be
+      * redrawn.
+      *
+      * @param height new screen height
+      */
+     public void setHeight(final int height);
+     /**
+      * Change the width and height.  Everything on-screen will be destroyed
+      * and must be redrawn.
+      *
+      * @param width new screen width
+      * @param height new screen height
+      */
+     public void setDimensions(final int width, final int height);
+     /**
+      * Get the height.
+      *
+      * @return current screen height
+      */
+     public int getHeight();
+     /**
+      * Get the width.
+      *
+      * @return current screen width
+      */
+     public int getWidth();
+     /**
+      * Reset screen to not-bold, white-on-black.  Also flushes the offset and
+      * clip variables.
+      */
+     public void reset();
+     /**
+      * Flush the offset and clip variables.
+      */
+     public void resetClipping();
+     /**
+      * Clear the logical screen.
+      */
+     public void clear();
+     /**
+      * Draw a box with a border and empty background.
+      *
+      * @param left left column of box.  0 is the left-most row.
+      * @param top top row of the box.  0 is the top-most row.
+      * @param right right column of box
+      * @param bottom bottom row of the box
+      * @param border attributes to use for the border
+      * @param background attributes to use for the background
+      */
+     public void drawBox(final int left, final int top,
+         final int right, final int bottom,
+         final CellAttributes border, final CellAttributes background);
+     /**
+      * Draw a box with a border and empty background.
+      *
+      * @param left left column of box.  0 is the left-most row.
+      * @param top top row of the box.  0 is the top-most row.
+      * @param right right column of box
+      * @param bottom bottom row of the box
+      * @param border attributes to use for the border
+      * @param background attributes to use for the background
+      * @param borderType if 1, draw a single-line border; if 2, draw a
+      * double-line border; if 3, draw double-line top/bottom edges and
+      * single-line left/right edges (like Qmodem)
+      * @param shadow if true, draw a "shadow" on the box
+      */
+     public void drawBox(final int left, final int top,
+         final int right, final int bottom,
+         final CellAttributes border, final CellAttributes background,
+         final int borderType, final boolean shadow);
+     /**
+      * Draw a box shadow.
+      *
+      * @param left left column of box.  0 is the left-most row.
+      * @param top top row of the box.  0 is the top-most row.
+      * @param right right column of box
+      * @param bottom bottom row of the box
+      */
+     public void drawBoxShadow(final int left, final int top,
+         final int right, final int bottom);
+     /**
+      * Clear the physical screen.
+      */
+     public void clearPhysical();
+     /**
+      * Unset every image cell on one row of the physical screen, forcing
+      * images on that row to be redrawn.
+      *
+      * @param y row coordinate.  0 is the top-most row.
+      */
+     public void unsetImageRow(final int y);
+     /**
+      * Classes must provide an implementation to push the logical screen to
+      * the physical device.
+      */
+     public void flushPhysical();
+     /**
+      * Put the cursor at (x,y).
+      *
+      * @param visible if true, the cursor should be visible
+      * @param x column coordinate to put the cursor on
+      * @param y row coordinate to put the cursor on
+      */
+     public void putCursor(final boolean visible, final int x, final int y);
+     /**
+      * Hide the cursor.
+      */
+     public void hideCursor();
+     /**
+      * Get the cursor visibility.
+      *
+      * @return true if the cursor is visible
+      */
+     public boolean isCursorVisible();
+     /**
+      * Get the cursor X position.
+      *
+      * @return the cursor x column position
+      */
+     public int getCursorX();
+     /**
+      * Get the cursor Y position.
+      *
+      * @return the cursor y row position
+      */
+     public int getCursorY();
+     /**
+      * Set the window title.
+      *
+      * @param title the new title
+      */
+     public void setTitle(final String title);
+     /**
+      * Get the width of a character cell in pixels.
+      *
+      * @return the width in pixels of a character cell
+      */
+     public int getTextWidth();
+     /**
+      * Get the height of a character cell in pixels.
+      *
+      * @return the height in pixels of a character cell
+      */
+     public int getTextHeight();
+ }
index 0000000000000000000000000000000000000000,8a29ce0b8695a13eacdfb3b1a84929c3ac925abd..8a29ce0b8695a13eacdfb3b1a84929c3ac925abd
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,83 +1,83 @@@
+ /*
+  * Jexer - Java Text User Interface
+  *
+  * The MIT License (MIT)
+  *
+  * 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"),
+  * to deal in the Software without restriction, including without limitation
+  * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+  * and/or sell copies of the Software, and to permit persons to whom the
+  * Software is furnished to do so, subject to the following conditions:
+  *
+  * The above copyright notice and this permission notice shall be included in
+  * all copies or substantial portions of the Software.
+  *
+  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+  * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+  * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+  * DEALINGS IN THE SOFTWARE.
+  *
+  * @author Kevin Lamonte [kevin.lamonte@gmail.com]
+  * @version 1
+  */
+ package jexer.backend;
+ /**
+  * SessionInfo is used to store per-session properties that are determined at
+  * different layers of the communication stack.
+  */
+ public interface SessionInfo {
+     /**
+      * Username getter.
+      *
+      * @return the username
+      */
+     public String getUsername();
+     /**
+      * Username setter.
+      *
+      * @param username the value
+      */
+     public void setUsername(String username);
+     /**
+      * Language getter.
+      *
+      * @return the language
+      */
+     public String getLanguage();
+     /**
+      * Language setter.
+      *
+      * @param language the value
+      */
+     public void setLanguage(String language);
+     /**
+      * Text window width getter.
+      *
+      * @return the window width
+      */
+     public int getWindowWidth();
+     /**
+      * Text window height getter.
+      *
+      * @return the window height
+      */
+     public int getWindowHeight();
+     /**
+      * Re-query the text window size.
+      */
+     public void queryWindowSize();
+ }
index 0000000000000000000000000000000000000000,8a342b604f8d438988df943c741b91f0d54b2f06..8a342b604f8d438988df943c741b91f0d54b2f06
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,167 +1,167 @@@
+ /*
+  * Jexer - Java Text User Interface
+  *
+  * The MIT License (MIT)
+  *
+  * 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"),
+  * to deal in the Software without restriction, including without limitation
+  * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+  * and/or sell copies of the Software, and to permit persons to whom the
+  * Software is furnished to do so, subject to the following conditions:
+  *
+  * The above copyright notice and this permission notice shall be included in
+  * all copies or substantial portions of the Software.
+  *
+  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+  * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+  * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+  * DEALINGS IN THE SOFTWARE.
+  *
+  * @author Kevin Lamonte [kevin.lamonte@gmail.com]
+  * @version 1
+  */
+ package jexer.backend;
+ import java.awt.Font;
+ import javax.swing.JComponent;
+ /**
+  * This class uses standard Swing calls to handle screen, keyboard, and mouse
+  * I/O.
+  */
+ public class SwingBackend extends GenericBackend {
+     // ------------------------------------------------------------------------
+     // Constructors -----------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Public constructor.  The window will be 80x25 with font size 20 pts.
+      */
+     public SwingBackend() {
+         this(null, 80, 25, 20);
+     }
+     /**
+      * Public constructor.  The window will be 80x25 with font size 20 pts.
+      *
+      * @param listener the object this backend needs to wake up when new
+      * input comes in
+      */
+     public SwingBackend(final Object listener) {
+         this(listener, 80, 25, 20);
+     }
+     /**
+      * Public constructor will spawn a new JFrame with font size 20 pts.
+      *
+      * @param windowWidth the number of text columns to start with
+      * @param windowHeight the number of text rows to start with
+      */
+     public SwingBackend(final int windowWidth, final int windowHeight) {
+         this(null, windowWidth, windowHeight, 20);
+     }
+     /**
+      * Public constructor will spawn a new JFrame.
+      *
+      * @param windowWidth the number of text columns to start with
+      * @param windowHeight the number of text rows to start with
+      * @param fontSize the size in points.  Good values to pick are: 16, 20,
+      * 22, and 24.
+      */
+     public SwingBackend(final int windowWidth, final int windowHeight,
+         final int fontSize) {
+         this(null, windowWidth, windowHeight, fontSize);
+     }
+     /**
+      * Public constructor will spawn a new JFrame.
+      *
+      * @param listener the object this backend needs to wake up when new
+      * input comes in
+      * @param windowWidth the number of text columns to start with
+      * @param windowHeight the number of text rows to start with
+      * @param fontSize the size in points.  Good values to pick are: 16, 20,
+      * 22, and 24.
+      */
+     public SwingBackend(final Object listener, final int windowWidth,
+         final int windowHeight, final int fontSize) {
+         // Create a Swing backend using a JFrame
+         terminal = new SwingTerminal(windowWidth, windowHeight, fontSize,
+             listener);
+         // Hang onto the session info
+         this.sessionInfo = ((SwingTerminal) terminal).getSessionInfo();
+         // SwingTerminal is the screen too
+         screen = (SwingTerminal) terminal;
+     }
+     /**
+      * Public constructor will render onto a JComponent.
+      *
+      * @param component the Swing component to render to
+      * @param listener the object this backend needs to wake up when new
+      * input comes in
+      * @param windowWidth the number of text columns to start with
+      * @param windowHeight the number of text rows to start with
+      * @param fontSize the size in points.  Good values to pick are: 16, 20,
+      * 22, and 24.
+      */
+     public SwingBackend(final JComponent component, final Object listener,
+         final int windowWidth, final int windowHeight, final int fontSize) {
+         // Create a Swing backend using a JComponent
+         terminal = new SwingTerminal(component, windowWidth, windowHeight,
+             fontSize, listener);
+         // Hang onto the session info
+         this.sessionInfo = ((SwingTerminal) terminal).getSessionInfo();
+         // SwingTerminal is the screen too
+         screen = (SwingTerminal) terminal;
+     }
+     // ------------------------------------------------------------------------
+     // SwingBackend -----------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Set to a new font, and resize the screen to match its dimensions.
+      *
+      * @param font the new font
+      */
+     public void setFont(final Font font) {
+         ((SwingTerminal) terminal).setFont(font);
+     }
+     /**
+      * Get the number of millis to wait before switching the blink from
+      * visible to invisible.
+      *
+      * @return the number of milli to wait before switching the blink from
+      * visible to invisible
+      */
+     public long getBlinkMillis() {
+         return ((SwingTerminal) terminal).getBlinkMillis();
+     }
+     /**
+      * Getter for the underlying Swing component.
+      *
+      * @return the SwingComponent
+      */
+     public SwingComponent getSwingComponent() {
+         return ((SwingTerminal) terminal).getSwingComponent();
+     }
+ }
index 0000000000000000000000000000000000000000,3d1074cf889070d6bc1c7eee4d9be1da80b92d06..3d1074cf889070d6bc1c7eee4d9be1da80b92d06
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,601 +1,601 @@@
+ /*
+  * Jexer - Java Text User Interface
+  *
+  * The MIT License (MIT)
+  *
+  * 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"),
+  * to deal in the Software without restriction, including without limitation
+  * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+  * and/or sell copies of the Software, and to permit persons to whom the
+  * Software is furnished to do so, subject to the following conditions:
+  *
+  * The above copyright notice and this permission notice shall be included in
+  * all copies or substantial portions of the Software.
+  *
+  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+  * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+  * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+  * DEALINGS IN THE SOFTWARE.
+  *
+  * @author Kevin Lamonte [kevin.lamonte@gmail.com]
+  * @version 1
+  */
+ package jexer.backend;
+ import java.awt.Color;
+ import java.awt.Cursor;
+ import java.awt.Font;
+ import java.awt.Graphics;
+ import java.awt.Insets;
+ import java.awt.Point;
+ import java.awt.Toolkit;
+ import java.awt.event.ComponentListener;
+ import java.awt.event.KeyListener;
+ import java.awt.event.MouseListener;
+ import java.awt.event.MouseMotionListener;
+ import java.awt.event.MouseWheelListener;
+ import java.awt.event.WindowListener;
+ import java.awt.image.BufferedImage;
+ import java.awt.image.BufferStrategy;
+ import java.io.IOException;
+ import javax.imageio.ImageIO;
+ import javax.swing.JComponent;
+ import javax.swing.JFrame;
+ import javax.swing.SwingUtilities;
+ /**
+  * Wrapper for integrating with Swing, because JFrame and JComponent have
+  * separate hierarchies.
+  */
+ class SwingComponent {
+     // ------------------------------------------------------------------------
+     // Variables --------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * If true, use triple buffering when drawing to a JFrame.
+      */
+     public static boolean tripleBuffer = true;
+     /**
+      * The frame reference, if we are drawing to a JFrame.
+      */
+     private JFrame frame;
+     /**
+      * The component reference, if we are drawing to a JComponent.
+      */
+     private JComponent component;
+     /**
+      * An optional border in pixels to add.
+      */
+     private static final int BORDER = 1;
+     /**
+      * Adjustable Insets for this component.  This has the effect of adding a
+      * black border around the drawing area.
+      */
+     Insets adjustInsets = new Insets(BORDER + 5, BORDER, BORDER, BORDER);
+     // ------------------------------------------------------------------------
+     // Constructors -----------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Construct using a JFrame.
+      *
+      * @param frame the JFrame to draw to
+      */
+     public SwingComponent(final JFrame frame) {
+         this.frame = frame;
+         setupFrame();
+     }
+     /**
+      * Construct using a JComponent.
+      *
+      * @param component the JComponent to draw to
+      */
+     public SwingComponent(final JComponent component) {
+         this.component = component;
+         setupComponent();
+     }
+     // ------------------------------------------------------------------------
+     // SwingComponent ---------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Get the BufferStrategy object needed for triple-buffering.
+      *
+      * @return the BufferStrategy
+      * @throws IllegalArgumentException if this function is called when
+      * not rendering to a JFrame
+      */
+     public BufferStrategy getBufferStrategy() {
+         if (frame != null) {
+             return frame.getBufferStrategy();
+         } else {
+             throw new IllegalArgumentException("BufferStrategy not used " +
+                 "for JComponent access");
+         }
+     }
+     /**
+      * Get the JFrame reference.
+      *
+      * @return the frame, or null if this is drawing to a JComponent
+      */
+     public JFrame getFrame() {
+         return frame;
+     }
+     /**
+      * Get the JComponent reference.
+      *
+      * @return the component, or null if this is drawing to a JFrame
+      */
+     public JComponent getComponent() {
+         return component;
+     }
+     /**
+      * Setup to render to an existing JComponent.
+      */
+     public void setupComponent() {
+         component.setBackground(Color.black);
+         if (System.getProperty("jexer.Swing.mouseImage") != null) {
+             component.setCursor(getMouseImage());
+         } else if (System.getProperty("jexer.Swing.mouseStyle") != null) {
+             component.setCursor(getMouseCursor());
+         } else if (System.getProperty("jexer.textMouse",
+                 "true").equals("false")
+         ) {
+             // If the user has suppressed the text mouse, don't kill the X11
+             // mouse.
+             component.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR));
+         } else {
+             // Kill the X11 cursor
+             // Transparent 16 x 16 pixel cursor image.
+             BufferedImage cursorImg = new BufferedImage(16, 16,
+                 BufferedImage.TYPE_INT_ARGB);
+             // Create a new blank cursor.
+             Cursor blankCursor = Toolkit.getDefaultToolkit().createCustomCursor(
+                 cursorImg, new Point(0, 0), "blank cursor");
+             component.setCursor(blankCursor);
+         }
+         // Be capable of seeing Tab / Shift-Tab
+         component.setFocusTraversalKeysEnabled(false);
+     }
+     /**
+      * Setup to render to an existing JFrame.
+      */
+     public void setupFrame() {
+         frame.setTitle("Jexer Application");
+         frame.setBackground(Color.black);
+         frame.pack();
+         if (System.getProperty("jexer.Swing.mouseImage") != null) {
+             frame.setCursor(getMouseImage());
+         } else if (System.getProperty("jexer.Swing.mouseStyle") != null) {
+             frame.setCursor(getMouseCursor());
+         } else if (System.getProperty("jexer.textMouse",
+                 "true").equals("false")
+         ) {
+             // If the user has suppressed the text mouse, don't kill the X11
+             // mouse.
+             frame.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR));
+         } else {
+             // Kill the X11 cursor
+             // Transparent 16 x 16 pixel cursor image.
+             BufferedImage cursorImg = new BufferedImage(16, 16,
+                 BufferedImage.TYPE_INT_ARGB);
+             // Create a new blank cursor.
+             Cursor blankCursor = Toolkit.getDefaultToolkit().createCustomCursor(
+                 cursorImg, new Point(0, 0), "blank cursor");
+             frame.setCursor(blankCursor);
+         }
+         // Be capable of seeing Tab / Shift-Tab
+         frame.setFocusTraversalKeysEnabled(false);
+         // Setup triple-buffering
+         if (tripleBuffer) {
+             frame.setIgnoreRepaint(true);
+             frame.createBufferStrategy(3);
+         }
+     }
+     /**
+      * Load an image named in jexer.Swing.mouseImage as the mouse cursor.
+      * The image must be on the classpath.
+      *
+      * @return the cursor
+      */
+     private Cursor getMouseImage() {
+         Cursor cursor = Cursor.getDefaultCursor();
+         String filename = System.getProperty("jexer.Swing.mouseImage");
+         assert (filename != null);
+         try {
+             ClassLoader loader = Thread.currentThread().
+                 getContextClassLoader();
+             java.net.URL url = loader.getResource(filename);
+             if (url == null) {
+                 // User named a file, but it's not on the classpath.  Bail
+                 // out.
+                 return cursor;
+             }
+             BufferedImage cursorImage = ImageIO.read(url);
+             java.awt.Dimension cursorSize = Toolkit.getDefaultToolkit().
+                 getBestCursorSize(
+                         cursorImage.getWidth(), cursorImage.getHeight());
+             cursor = Toolkit.getDefaultToolkit().createCustomCursor(cursorImage,
+                 new Point((int) Math.min(cursorImage.getWidth() / 2,
+                         cursorSize.getWidth() - 1),
+                     (int) Math.min(cursorImage.getHeight() / 2,
+                         cursorSize.getHeight() - 1)),
+                 "custom cursor");
+         } catch (IOException e) {
+             e.printStackTrace();
+         }
+         return cursor;
+     }
+     /**
+      * Get the appropriate mouse cursor based on jexer.Swing.mouseStyle.
+      *
+      * @return the cursor
+      */
+     private Cursor getMouseCursor() {
+         Cursor cursor = Cursor.getDefaultCursor();
+         String style = System.getProperty("jexer.Swing.mouseStyle");
+         assert (style != null);
+         style = style.toLowerCase();
+         if (style.equals("none")) {
+             // Transparent 16 x 16 pixel cursor image.
+             BufferedImage cursorImg = new BufferedImage(16, 16,
+                 BufferedImage.TYPE_INT_ARGB);
+             // Create a new blank cursor.
+             cursor = Toolkit.getDefaultToolkit().createCustomCursor(
+                 cursorImg, new Point(0, 0), "blank cursor");
+         } else if (style.equals("default")) {
+             cursor = Cursor.getPredefinedCursor(Cursor.DEFAULT_CURSOR);
+         } else if (style.equals("hand")) {
+             cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR);
+         } else if (style.equals("text")) {
+             cursor = Cursor.getPredefinedCursor(Cursor.TEXT_CURSOR);
+         } else if (style.equals("move")) {
+             cursor = Cursor.getPredefinedCursor(Cursor.MOVE_CURSOR);
+         } else if (style.equals("crosshair")) {
+             cursor = Cursor.getPredefinedCursor(Cursor.CROSSHAIR_CURSOR);
+         }
+         return cursor;
+     }
+     /**
+      * Set the window title.
+      *
+      * @param title the new title
+      */
+     public void setTitle(final String title) {
+         if (frame != null) {
+             frame.setTitle(title);
+         }
+     }
+     /**
+      * Paints this component.
+      *
+      * @param g the graphics context to use for painting
+      */
+     public void paint(Graphics g) {
+         if (frame != null) {
+             frame.paint(g);
+         } else {
+             component.paint(g);
+         }
+     }
+     /**
+      * Repaints this component.
+      */
+     public void repaint() {
+         if (frame != null) {
+             frame.repaint();
+         } else {
+             component.repaint();
+         }
+     }
+     /**
+      * Repaints the specified rectangle of this component.
+      *
+      * @param x the x coordinate
+      * @param y the y coordinate
+      * @param width the width
+      * @param height the height
+      */
+     public void repaint(int x, int y, int width, int height) {
+         if (frame != null) {
+             frame.repaint(x, y, width, height);
+         } else {
+             component.repaint(x, y, width, height);
+         }
+     }
+     /**
+      * If a border has been set on this component, returns the border's
+      * insets; otherwise calls super.getInsets.
+      *
+      * @return the value of the insets property
+      */
+     public Insets getInsets() {
+         Insets swingInsets = null;
+         if (frame != null) {
+             swingInsets = frame.getInsets();
+         } else {
+             swingInsets = component.getInsets();
+         }
+         Insets result = new Insets(swingInsets.top + adjustInsets.top,
+             swingInsets.left + adjustInsets.left,
+             swingInsets.bottom + adjustInsets.bottom,
+             swingInsets.right + adjustInsets.right);
+         return result;
+     }
+     /**
+      * Returns the current width of this component.
+      *
+      * @return the current width of this component
+      */
+     public int getWidth() {
+         if (frame != null) {
+             return frame.getWidth();
+         } else {
+             return component.getWidth();
+         }
+     }
+     /**
+      * Returns the current height of this component.
+      *
+      * @return the current height of this component
+      */
+     public int getHeight() {
+         if (frame != null) {
+             return frame.getHeight();
+         } else {
+             return component.getHeight();
+         }
+     }
+     /**
+      * Gets the font of this component.
+      *
+      * @return this component's font; if a font has not been set for this
+      * component, the font of its parent is returned
+      */
+     public Font getFont() {
+         if (frame != null) {
+             return frame.getFont();
+         } else {
+             return component.getFont();
+         }
+     }
+     /**
+      * Sets the font of this component.
+      *
+      * @param f the font to become this component's font; if this parameter
+      * is null then this component will inherit the font of its parent
+      */
+     public void setFont(final Font f) {
+         if (frame != null) {
+             frame.setFont(f);
+         } else {
+             component.setFont(f);
+         }
+     }
+     /**
+      * Shows or hides this Window depending on the value of parameter b.
+      *
+      * @param b if true, make visible, else make invisible
+      */
+     public void setVisible(final boolean b) {
+         if (frame != null) {
+             frame.setVisible(b);
+         } else {
+             component.setVisible(b);
+         }
+     }
+     /**
+      * Creates a graphics context for this component. This method will return
+      * null if this component is currently not displayable.
+      *
+      * @return a graphics context for this component, or null if it has none
+      */
+     public Graphics getGraphics() {
+         if (frame != null) {
+             return frame.getGraphics();
+         } else {
+             return component.getGraphics();
+         }
+     }
+     /**
+      * Releases all of the native screen resources used by this Window, its
+      * subcomponents, and all of its owned children. That is, the resources
+      * for these Components will be destroyed, any memory they consume will
+      * be returned to the OS, and they will be marked as undisplayable.
+      */
+     public void dispose() {
+         if (frame != null) {
+             frame.dispose();
+         } else {
+             component.getParent().remove(component);
+         }
+     }
+     /**
+      * Resize the component to match the font dimensions.
+      *
+      * @param width the new width in pixels
+      * @param height the new height in pixels
+      */
+     public void setDimensions(final int width, final int height) {
+         if (SwingUtilities.isEventDispatchThread()) {
+             // We are in the Swing thread and can safely set the size.
+             // Figure out the thickness of borders and use that to set the
+             // final size.
+             if (frame != null) {
+                 Insets insets = getInsets();
+                 frame.setSize(width + insets.left + insets.right,
+                     height + insets.top + insets.bottom);
+             } else {
+                 Insets insets = getInsets();
+                 component.setSize(width + insets.left + insets.right,
+                     height + insets.top + insets.bottom);
+             }
+             return;
+         }
+         SwingUtilities.invokeLater(new Runnable() {
+             public void run() {
+                 // Figure out the thickness of borders and use that to set
+                 // the final size.
+                 if (frame != null) {
+                     Insets insets = getInsets();
+                     frame.setSize(width + insets.left + insets.right,
+                         height + insets.top + insets.bottom);
+                 } else {
+                     Insets insets = getInsets();
+                     component.setSize(width + insets.left + insets.right,
+                         height + insets.top + insets.bottom);
+                 }
+             }
+         });
+     }
+     /**
+      * Adds the specified component listener to receive component events from
+      * this component. If listener l is null, no exception is thrown and no
+      * action is performed.
+      *
+      * @param l the component listener
+      */
+     public void addComponentListener(ComponentListener l) {
+         if (frame != null) {
+             frame.addComponentListener(l);
+         } else {
+             component.addComponentListener(l);
+         }
+     }
+     /**
+      * Adds the specified key listener to receive key events from this
+      * component. If l is null, no exception is thrown and no action is
+      * performed.
+      *
+      * @param l the key listener.
+      */
+     public void addKeyListener(KeyListener l) {
+         if (frame != null) {
+             frame.addKeyListener(l);
+         } else {
+             component.addKeyListener(l);
+         }
+     }
+     /**
+      * Adds the specified mouse listener to receive mouse events from this
+      * component. If listener l is null, no exception is thrown and no action
+      * is performed.
+      *
+      * @param l the mouse listener
+      */
+     public void addMouseListener(MouseListener l) {
+         if (frame != null) {
+             frame.addMouseListener(l);
+         } else {
+             component.addMouseListener(l);
+         }
+     }
+     /**
+      * Adds the specified mouse motion listener to receive mouse motion
+      * events from this component. If listener l is null, no exception is
+      * thrown and no action is performed.
+      *
+      * @param l the mouse motion listener
+      */
+     public void addMouseMotionListener(MouseMotionListener l) {
+         if (frame != null) {
+             frame.addMouseMotionListener(l);
+         } else {
+             component.addMouseMotionListener(l);
+         }
+     }
+     /**
+      * Adds the specified mouse wheel listener to receive mouse wheel events
+      * from this component. Containers also receive mouse wheel events from
+      * sub-components.
+      *
+      * @param l the mouse wheel listener
+      */
+     public void addMouseWheelListener(MouseWheelListener l) {
+         if (frame != null) {
+             frame.addMouseWheelListener(l);
+         } else {
+             component.addMouseWheelListener(l);
+         }
+     }
+     /**
+      * Adds the specified window listener to receive window events from this
+      * window. If l is null, no exception is thrown and no action is
+      * performed.
+      *
+      * @param l the window listener
+      */
+     public void addWindowListener(WindowListener l) {
+         if (frame != null) {
+             frame.addWindowListener(l);
+         }
+     }
+     /**
+      * Requests that this Component get the input focus, if this Component's
+      * top-level ancestor is already the focused Window.
+      */
+     public void requestFocusInWindow() {
+         if (frame != null) {
+             frame.requestFocusInWindow();
+         } else {
+             component.requestFocusInWindow();
+         }
+     }
+ }
index 0000000000000000000000000000000000000000,2f74d7012130e0eeda1f44c4721396d645493b35..2f74d7012130e0eeda1f44c4721396d645493b35
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,226 +1,226 @@@
+ /*
+  * Jexer - Java Text User Interface
+  *
+  * The MIT License (MIT)
+  *
+  * 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"),
+  * to deal in the Software without restriction, including without limitation
+  * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+  * and/or sell copies of the Software, and to permit persons to whom the
+  * Software is furnished to do so, subject to the following conditions:
+  *
+  * The above copyright notice and this permission notice shall be included in
+  * all copies or substantial portions of the Software.
+  *
+  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+  * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+  * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+  * DEALINGS IN THE SOFTWARE.
+  *
+  * @author Kevin Lamonte [kevin.lamonte@gmail.com]
+  * @version 1
+  */
+ package jexer.backend;
+ import java.awt.Insets;
+ /**
+  * SwingSessionInfo provides a session implementation with a callback into
+  * Swing to support queryWindowSize().  The username is blank, language is
+  * "en_US", with a 80x25 text window.
+  */
+ public class SwingSessionInfo implements SessionInfo {
+     // ------------------------------------------------------------------------
+     // Variables --------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * The Swing JFrame or JComponent.
+      */
+     private SwingComponent swing;
+     /**
+      * The width of a text cell in pixels.
+      */
+     private int textWidth = 10;
+     /**
+      * The height of a text cell in pixels.
+      */
+     private int textHeight = 10;
+     /**
+      * User name.
+      */
+     private String username = "";
+     /**
+      * Language.
+      */
+     private String language = "en_US";
+     /**
+      * Text window width.
+      */
+     private int windowWidth = 80;
+     /**
+      * Text window height.
+      */
+     private int windowHeight = 25;
+     // ------------------------------------------------------------------------
+     // Constructors -----------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Public constructor.
+      *
+      * @param swing the Swing JFrame or JComponent
+      * @param textWidth the width of a cell in pixels
+      * @param textHeight the height of a cell in pixels
+      */
+     public SwingSessionInfo(final SwingComponent swing, final int textWidth,
+         final int textHeight) {
+         this.swing      = swing;
+         this.textWidth  = textWidth;
+         this.textHeight = textHeight;
+     }
+     /**
+      * Public constructor.
+      *
+      * @param swing the Swing JFrame or JComponent
+      * @param textWidth the width of a cell in pixels
+      * @param textHeight the height of a cell in pixels
+      * @param width the number of columns
+      * @param height the number of rows
+      */
+     public SwingSessionInfo(final SwingComponent swing, final int textWidth,
+         final int textHeight, final int width, final int height) {
+         this.swing              = swing;
+         this.textWidth          = textWidth;
+         this.textHeight         = textHeight;
+         this.windowWidth        = width;
+         this.windowHeight       = height;
+     }
+     // ------------------------------------------------------------------------
+     // SessionInfo ------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Username getter.
+      *
+      * @return the username
+      */
+     public String getUsername() {
+         return this.username;
+     }
+     /**
+      * Username setter.
+      *
+      * @param username the value
+      */
+     public void setUsername(final String username) {
+         this.username = username;
+     }
+     /**
+      * Language getter.
+      *
+      * @return the language
+      */
+     public String getLanguage() {
+         return this.language;
+     }
+     /**
+      * Language setter.
+      *
+      * @param language the value
+      */
+     public void setLanguage(final String language) {
+         this.language = language;
+     }
+     /**
+      * Text window width getter.
+      *
+      * @return the window width
+      */
+     public int getWindowWidth() {
+         return windowWidth;
+     }
+     /**
+      * Text window height getter.
+      *
+      * @return the window height
+      */
+     public int getWindowHeight() {
+         return windowHeight;
+     }
+     /**
+      * Re-query the text window size.
+      */
+     public void queryWindowSize() {
+         Insets insets = swing.getInsets();
+         int width = swing.getWidth() - insets.left - insets.right;
+         int height = swing.getHeight() - insets.top - insets.bottom;
+         // In theory, if Java reported pixel-perfect dimensions, the
+         // expressions above would precisely line up with the requested
+         // window size from SwingComponent.setDimensions().  In practice,
+         // there appears to be a small difference.  Add half a text cell in
+         // both directions before the division to hopefully reach the same
+         // result as setDimensions() was supposed to give us.
+         width += (textWidth / 2);
+         height += (textHeight / 2);
+         windowWidth = width / textWidth;
+         windowHeight = height / textHeight;
+         /*
+         System.err.printf("queryWindowSize(): frame %d %d window %d %d\n",
+             swing.getWidth(), swing.getHeight(),
+             windowWidth, windowHeight);
+         */
+     }
+     // ------------------------------------------------------------------------
+     // SwingSessionInfo -------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Set the dimensions of a single text cell.
+      *
+      * @param textWidth the width of a cell in pixels
+      * @param textHeight the height of a cell in pixels
+      */
+     public void setTextCellDimensions(final int textWidth,
+         final int textHeight) {
+         this.textWidth  = textWidth;
+         this.textHeight = textHeight;
+     }
+     /**
+      * Getter for the underlying Swing component.
+      *
+      * @return the SwingComponent
+      */
+     public SwingComponent getSwingComponent() {
+         return swing;
+     }
+ }
index 0000000000000000000000000000000000000000,f0ba3552fd52b812a91a06be0f97c9adb96604e7..f0ba3552fd52b812a91a06be0f97c9adb96604e7
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,2331 +1,2331 @@@
+ /*
+  * Jexer - Java Text User Interface
+  *
+  * The MIT License (MIT)
+  *
+  * 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"),
+  * to deal in the Software without restriction, including without limitation
+  * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+  * and/or sell copies of the Software, and to permit persons to whom the
+  * Software is furnished to do so, subject to the following conditions:
+  *
+  * The above copyright notice and this permission notice shall be included in
+  * all copies or substantial portions of the Software.
+  *
+  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+  * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+  * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+  * DEALINGS IN THE SOFTWARE.
+  *
+  * @author Kevin Lamonte [kevin.lamonte@gmail.com]
+  * @version 1
+  */
+ package jexer.backend;
+ import java.awt.BorderLayout;
+ import java.awt.Color;
+ import java.awt.Font;
+ import java.awt.FontMetrics;
+ import java.awt.Graphics2D;
+ import java.awt.Graphics;
+ import java.awt.Insets;
+ import java.awt.Rectangle;
+ import java.awt.Toolkit;
+ import java.awt.event.ComponentEvent;
+ import java.awt.event.ComponentListener;
+ import java.awt.event.KeyEvent;
+ import java.awt.event.KeyListener;
+ import java.awt.event.MouseEvent;
+ import java.awt.event.MouseListener;
+ import java.awt.event.MouseMotionListener;
+ import java.awt.event.MouseWheelEvent;
+ import java.awt.event.MouseWheelListener;
+ import java.awt.event.WindowEvent;
+ import java.awt.event.WindowListener;
+ import java.awt.geom.Rectangle2D;
+ import java.awt.image.BufferedImage;
+ import java.io.InputStream;
+ import java.util.ArrayList;
+ import java.util.HashMap;
+ import java.util.List;
+ import java.util.Map;
+ import javax.swing.JComponent;
+ import javax.swing.JFrame;
+ import javax.swing.ImageIcon;
+ import javax.swing.SwingUtilities;
+ import jexer.TKeypress;
+ import jexer.bits.Cell;
+ import jexer.bits.CellAttributes;
+ 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 Screen backend reads keystrokes and mouse events and draws to either
+  * a Java Swing JFrame (potentially triple-buffered) or a JComponent.
+  *
+  * This class is a bit of an inversion of typical GUI classes.  It performs
+  * all of the drawing logic from SwingTerminal (which is not a Swing class),
+  * and uses a SwingComponent wrapper class to call the JFrame or JComponent
+  * methods.
+  */
+ public class SwingTerminal extends LogicalScreen
+                            implements TerminalReader,
+                                       ComponentListener, KeyListener,
+                                       MouseListener, MouseMotionListener,
+                                       MouseWheelListener, WindowListener {
+     // ------------------------------------------------------------------------
+     // Constants --------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * The icon image location.
+      */
+     private static final String ICONFILE = "jexer_logo_128.png";
+     /**
+      * The terminus font resource filename.
+      */
+     public static final String FONTFILE = "terminus-ttf-4.39/TerminusTTF-Bold-4.39.ttf";
+     /**
+      * Cursor style to draw.
+      */
+     public enum CursorStyle {
+         /**
+          * Use an underscore for the cursor.
+          */
+         UNDERLINE,
+         /**
+          * Use a solid block for the cursor.
+          */
+         BLOCK,
+         /**
+          * Use an outlined block for the cursor.
+          */
+         OUTLINE,
+         /**
+          * Use a vertical bar for the cursor.
+          */
+         VERTICAL_BAR,
+     }
+     // ------------------------------------------------------------------------
+     // Variables --------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     // Colors to map DOS colors to AWT colors.
+     private static Color MYBLACK;
+     private static Color MYRED;
+     private static Color MYGREEN;
+     private static Color MYYELLOW;
+     private static Color MYBLUE;
+     private static Color MYMAGENTA;
+     private static Color MYCYAN;
+     private static Color MYWHITE;
+     private static Color MYBOLD_BLACK;
+     private static Color MYBOLD_RED;
+     private static Color MYBOLD_GREEN;
+     private static Color MYBOLD_YELLOW;
+     private static Color MYBOLD_BLUE;
+     private static Color MYBOLD_MAGENTA;
+     private static Color MYBOLD_CYAN;
+     private static Color MYBOLD_WHITE;
+     /**
+      * When true, all the MYBLACK, MYRED, etc. colors are set.
+      */
+     private static boolean dosColors = false;
+     /**
+      * The Swing component or frame to draw to.
+      */
+     private SwingComponent swing;
+     /**
+      * A cache of previously-rendered glyphs for blinking text, when it is
+      * not visible.
+      */
+     private Map<Cell, BufferedImage> glyphCacheBlink;
+     /**
+      * A cache of previously-rendered glyphs for non-blinking, or
+      * blinking-and-visible, text.
+      */
+     private Map<Cell, BufferedImage> glyphCache;
+     /**
+      * If true, we were successful at getting the font dimensions.
+      */
+     private boolean gotFontDimensions = false;
+     /**
+      * The currently selected font.
+      */
+     private Font font = null;
+     /**
+      * The currently selected font size in points.
+      */
+     private int fontSize = 16;
+     /**
+      * Width of a character cell in pixels.
+      */
+     private int textWidth = 16;
+     /**
+      * Height of a character cell in pixels.
+      */
+     private int textHeight = 20;
+     /**
+      * Width of a character cell in pixels, as reported by font.
+      */
+     private int fontTextWidth = 1;
+     /**
+      * Height of a character cell in pixels, as reported by font.
+      */
+     private int fontTextHeight = 1;
+     /**
+      * Descent of a character cell in pixels.
+      */
+     private int maxDescent = 0;
+     /**
+      * System-dependent Y adjustment for text in the character cell.
+      */
+     private int textAdjustY = 0;
+     /**
+      * System-dependent X adjustment for text in the character cell.
+      */
+     private int textAdjustX = 0;
+     /**
+      * System-dependent height adjustment for text in the character cell.
+      */
+     private int textAdjustHeight = 0;
+     /**
+      * System-dependent width adjustment for text in the character cell.
+      */
+     private int textAdjustWidth = 0;
+     /**
+      * Top pixel absolute location.
+      */
+     private int top = 30;
+     /**
+      * Left pixel absolute location.
+      */
+     private int left = 30;
+     /**
+      * The cursor style to draw.
+      */
+     private CursorStyle cursorStyle = CursorStyle.UNDERLINE;
+     /**
+      * The number of millis to wait before switching the blink from visible
+      * to invisible.  Set to 0 or negative to disable blinking.
+      */
+     private long blinkMillis = 500;
+     /**
+      * If true, the cursor should be visible right now based on the blink
+      * time.
+      */
+     private boolean cursorBlinkVisible = true;
+     /**
+      * The time that the blink last flipped from visible to invisible or
+      * from invisible to visible.
+      */
+     private long lastBlinkTime = 0;
+     /**
+      * The session information.
+      */
+     private SwingSessionInfo sessionInfo;
+     /**
+      * The listening object that run() wakes up on new input.
+      */
+     private Object listener;
+     /**
+      * The event queue, filled up by a thread reading on input.
+      */
+     private List<TInputEvent> eventQueue;
+     /**
+      * The last reported mouse X position.
+      */
+     private int oldMouseX = -1;
+     /**
+      * The last reported mouse Y position.
+      */
+     private int oldMouseY = -1;
+     /**
+      * true if mouse1 was down.  Used to report mouse1 on the release event.
+      */
+     private boolean mouse1 = false;
+     /**
+      * true if mouse2 was down.  Used to report mouse2 on the release event.
+      */
+     private boolean mouse2 = false;
+     /**
+      * true if mouse3 was down.  Used to report mouse3 on the release event.
+      */
+     private boolean mouse3 = false;
+     // ------------------------------------------------------------------------
+     // Constructors -----------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Static constructor.
+      */
+     static {
+         setDOSColors();
+     }
+     /**
+      * Public constructor creates a new JFrame to render to.
+      *
+      * @param windowWidth the number of text columns to start with
+      * @param windowHeight the number of text rows to start with
+      * @param fontSize the size in points.  Good values to pick are: 16, 20,
+      * 22, and 24.
+      * @param listener the object this backend needs to wake up when new
+      * input comes in
+      */
+     public SwingTerminal(final int windowWidth, final int windowHeight,
+         final int fontSize, final Object listener) {
+         this.fontSize = fontSize;
+         reloadOptions();
+         try {
+             SwingUtilities.invokeAndWait(new Runnable() {
+                 public void run() {
+                     JFrame frame = new JFrame() {
+                         /**
+                          * Serializable version.
+                          */
+                         private static final long serialVersionUID = 1;
+                         /**
+                          * The code that performs the actual drawing.
+                          */
+                         public SwingTerminal screen = null;
+                         /*
+                          * Anonymous class initializer saves the screen
+                          * reference, so that paint() and the like call out
+                          * to SwingTerminal.
+                          */
+                         {
+                             this.screen = SwingTerminal.this;
+                         }
+                         /**
+                          * Update redraws the whole screen.
+                          *
+                          * @param gr the Swing Graphics context
+                          */
+                         @Override
+                         public void update(final Graphics gr) {
+                             // The default update clears the area.  Don't do
+                             // that, instead just paint it directly.
+                             paint(gr);
+                         }
+                         /**
+                          * Paint redraws the whole screen.
+                          *
+                          * @param gr the Swing Graphics context
+                          */
+                         @Override
+                         public void paint(final Graphics gr) {
+                             if (screen != null) {
+                                 screen.paint(gr);
+                             }
+                         }
+                     };
+                     // Set icon
+                     ClassLoader loader = Thread.currentThread().
+                         getContextClassLoader();
+                     frame.setIconImage((new ImageIcon(loader.
+                                 getResource(ICONFILE))).getImage());
+                     // Get the Swing component
+                     SwingTerminal.this.swing = new SwingComponent(frame);
+                     // Hang onto top and left for drawing.
+                     Insets insets = SwingTerminal.this.swing.getInsets();
+                     SwingTerminal.this.left = insets.left;
+                     SwingTerminal.this.top = insets.top;
+                     // Load the font so that we can set sessionInfo.
+                     setDefaultFont();
+                     // Get the default cols x rows and set component size
+                     // accordingly.
+                     SwingTerminal.this.sessionInfo =
+                         new SwingSessionInfo(SwingTerminal.this.swing,
+                             SwingTerminal.this.textWidth,
+                             SwingTerminal.this.textHeight,
+                             windowWidth, windowHeight);
+                     SwingTerminal.this.setDimensions(sessionInfo.
+                         getWindowWidth(), sessionInfo.getWindowHeight());
+                     SwingTerminal.this.resizeToScreen(true);
+                     SwingTerminal.this.swing.setVisible(true);
+                 }
+             });
+         } catch (java.lang.reflect.InvocationTargetException e) {
+             e.printStackTrace();
+         } catch (InterruptedException e) {
+             e.printStackTrace();
+         }
+         this.listener    = listener;
+         mouse1           = false;
+         mouse2           = false;
+         mouse3           = false;
+         eventQueue       = new ArrayList<TInputEvent>();
+         // Add listeners to Swing.
+         swing.addKeyListener(this);
+         swing.addWindowListener(this);
+         swing.addComponentListener(this);
+         swing.addMouseListener(this);
+         swing.addMouseMotionListener(this);
+         swing.addMouseWheelListener(this);
+     }
+     /**
+      * Public constructor renders to an existing JComponent.
+      *
+      * @param component the Swing component to render to
+      * @param windowWidth the number of text columns to start with
+      * @param windowHeight the number of text rows to start with
+      * @param fontSize the size in points.  Good values to pick are: 16, 20,
+      * 22, and 24.
+      * @param listener the object this backend needs to wake up when new
+      * input comes in
+      */
+     public SwingTerminal(final JComponent component, final int windowWidth,
+         final int windowHeight, final int fontSize, final Object listener) {
+         this.fontSize = fontSize;
+         reloadOptions();
+         try {
+             SwingUtilities.invokeAndWait(new Runnable() {
+                 public void run() {
+                     JComponent newComponent = new JComponent() {
+                         /**
+                          * Serializable version.
+                          */
+                         private static final long serialVersionUID = 1;
+                         /**
+                          * The code that performs the actual drawing.
+                          */
+                         public SwingTerminal screen = null;
+                         /*
+                          * Anonymous class initializer saves the screen
+                          * reference, so that paint() and the like call out
+                          * to SwingTerminal.
+                          */
+                         {
+                             this.screen = SwingTerminal.this;
+                         }
+                         /**
+                          * Update redraws the whole screen.
+                          *
+                          * @param gr the Swing Graphics context
+                          */
+                         @Override
+                         public void update(final Graphics gr) {
+                             // The default update clears the area.  Don't do
+                             // that, instead just paint it directly.
+                             paint(gr);
+                         }
+                         /**
+                          * Paint redraws the whole screen.
+                          *
+                          * @param gr the Swing Graphics context
+                          */
+                         @Override
+                         public void paint(final Graphics gr) {
+                             if (screen != null) {
+                                 screen.paint(gr);
+                             }
+                         }
+                     };
+                     component.setLayout(new BorderLayout());
+                     component.add(newComponent);
+                     // Allow key events to be received
+                     component.setFocusable(true);
+                     // Get the Swing component
+                     SwingTerminal.this.swing = new SwingComponent(component);
+                     // Hang onto top and left for drawing.
+                     Insets insets = SwingTerminal.this.swing.getInsets();
+                     SwingTerminal.this.left = insets.left;
+                     SwingTerminal.this.top = insets.top;
+                     // Load the font so that we can set sessionInfo.
+                     setDefaultFont();
+                     // Get the default cols x rows and set component size
+                     // accordingly.
+                     SwingTerminal.this.sessionInfo =
+                         new SwingSessionInfo(SwingTerminal.this.swing,
+                             SwingTerminal.this.textWidth,
+                             SwingTerminal.this.textHeight);
+                 }
+             });
+         } catch (java.lang.reflect.InvocationTargetException e) {
+             e.printStackTrace();
+         } catch (InterruptedException e) {
+             e.printStackTrace();
+         }
+         this.listener    = listener;
+         mouse1           = false;
+         mouse2           = false;
+         mouse3           = false;
+         eventQueue       = new ArrayList<TInputEvent>();
+         // Add listeners to Swing.
+         swing.addKeyListener(this);
+         swing.addWindowListener(this);
+         swing.addComponentListener(this);
+         swing.addMouseListener(this);
+         swing.addMouseMotionListener(this);
+         swing.addMouseWheelListener(this);
+     }
+     // ------------------------------------------------------------------------
+     // LogicalScreen ----------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Set the window title.
+      *
+      * @param title the new title
+      */
+     @Override
+     public void setTitle(final String title) {
+         swing.setTitle(title);
+     }
+     /**
+      * Push the logical screen to the physical device.
+      */
+     @Override
+     public void flushPhysical() {
+         // See if it is time to flip the blink time.
+         long nowTime = System.currentTimeMillis();
+         if (nowTime >= blinkMillis + lastBlinkTime) {
+             lastBlinkTime = nowTime;
+             cursorBlinkVisible = !cursorBlinkVisible;
+             // System.err.println("New lastBlinkTime: " + lastBlinkTime);
+         }
+         if ((swing.getFrame() != null)
+             && (swing.getBufferStrategy() != null)
+         ) {
+             do {
+                 do {
+                     clearPhysical();
+                     drawToSwing();
+                 } while (swing.getBufferStrategy().contentsRestored());
+                 swing.getBufferStrategy().show();
+                 Toolkit.getDefaultToolkit().sync();
+             } while (swing.getBufferStrategy().contentsLost());
+         } else {
+             // Non-triple-buffered, call drawToSwing() once
+             drawToSwing();
+         }
+     }
+     // ------------------------------------------------------------------------
+     // 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() {
+         shutdown();
+     }
+     /**
+      * 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;
+     }
+     /**
+      * Reload options from System properties.
+      */
+     public void reloadOptions() {
+         // Figure out my cursor style.
+         String cursorStyleString = System.getProperty(
+             "jexer.Swing.cursorStyle", "underline").toLowerCase();
+         if (cursorStyleString.equals("underline")) {
+             cursorStyle = CursorStyle.UNDERLINE;
+         } else if (cursorStyleString.equals("outline")) {
+             cursorStyle = CursorStyle.OUTLINE;
+         } else if (cursorStyleString.equals("block")) {
+             cursorStyle = CursorStyle.BLOCK;
+         } else if (cursorStyleString.equals("verticalbar")) {
+             cursorStyle = CursorStyle.VERTICAL_BAR;
+         }
+         // Pull the system property for triple buffering.
+         if (System.getProperty("jexer.Swing.tripleBuffer",
+                 "true").equals("true")
+         ) {
+             SwingComponent.tripleBuffer = true;
+         } else {
+             SwingComponent.tripleBuffer = false;
+         }
+         // Set custom colors
+         setCustomSystemColors();
+     }
+     // ------------------------------------------------------------------------
+     // SwingTerminal ----------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Get the width of a character cell in pixels.
+      *
+      * @return the width in pixels of a character cell
+      */
+     public int getTextWidth() {
+         return textWidth;
+     }
+     /**
+      * Get the height of a character cell in pixels.
+      *
+      * @return the height in pixels of a character cell
+      */
+     public int getTextHeight() {
+         return textHeight;
+     }
+     /**
+      * Setup Swing colors to match DOS color palette.
+      */
+     private static void setDOSColors() {
+         if (dosColors) {
+             return;
+         }
+         MYBLACK         = new Color(0x00, 0x00, 0x00);
+         MYRED           = new Color(0xa8, 0x00, 0x00);
+         MYGREEN         = new Color(0x00, 0xa8, 0x00);
+         MYYELLOW        = new Color(0xa8, 0x54, 0x00);
+         MYBLUE          = new Color(0x00, 0x00, 0xa8);
+         MYMAGENTA       = new Color(0xa8, 0x00, 0xa8);
+         MYCYAN          = new Color(0x00, 0xa8, 0xa8);
+         MYWHITE         = new Color(0xa8, 0xa8, 0xa8);
+         MYBOLD_BLACK    = new Color(0x54, 0x54, 0x54);
+         MYBOLD_RED      = new Color(0xfc, 0x54, 0x54);
+         MYBOLD_GREEN    = new Color(0x54, 0xfc, 0x54);
+         MYBOLD_YELLOW   = new Color(0xfc, 0xfc, 0x54);
+         MYBOLD_BLUE     = new Color(0x54, 0x54, 0xfc);
+         MYBOLD_MAGENTA  = new Color(0xfc, 0x54, 0xfc);
+         MYBOLD_CYAN     = new Color(0x54, 0xfc, 0xfc);
+         MYBOLD_WHITE    = new Color(0xfc, 0xfc, 0xfc);
+         dosColors = true;
+     }
+     /**
+      * Setup Swing colors to match those provided in system properties.
+      */
+     private static void setCustomSystemColors() {
+         synchronized (SwingTerminal.class) {
+             MYBLACK   = getCustomColor("jexer.Swing.color0", MYBLACK);
+             MYRED     = getCustomColor("jexer.Swing.color1", MYRED);
+             MYGREEN   = getCustomColor("jexer.Swing.color2", MYGREEN);
+             MYYELLOW  = getCustomColor("jexer.Swing.color3", MYYELLOW);
+             MYBLUE    = getCustomColor("jexer.Swing.color4", MYBLUE);
+             MYMAGENTA = getCustomColor("jexer.Swing.color5", MYMAGENTA);
+             MYCYAN    = getCustomColor("jexer.Swing.color6", MYCYAN);
+             MYWHITE   = getCustomColor("jexer.Swing.color7", MYWHITE);
+             MYBOLD_BLACK   = getCustomColor("jexer.Swing.color8", MYBOLD_BLACK);
+             MYBOLD_RED     = getCustomColor("jexer.Swing.color9", MYBOLD_RED);
+             MYBOLD_GREEN   = getCustomColor("jexer.Swing.color10", MYBOLD_GREEN);
+             MYBOLD_YELLOW  = getCustomColor("jexer.Swing.color11", MYBOLD_YELLOW);
+             MYBOLD_BLUE    = getCustomColor("jexer.Swing.color12", MYBOLD_BLUE);
+             MYBOLD_MAGENTA = getCustomColor("jexer.Swing.color13", MYBOLD_MAGENTA);
+             MYBOLD_CYAN    = getCustomColor("jexer.Swing.color14", MYBOLD_CYAN);
+             MYBOLD_WHITE   = getCustomColor("jexer.Swing.color15", MYBOLD_WHITE);
+         }
+     }
+     /**
+      * Setup one Swing 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 static Color getCustomColor(final String key,
+         final Color defaultColor) {
+         String rgb = System.getProperty(key);
+         if (rgb == null) {
+             return defaultColor;
+         }
+         if (rgb.startsWith("#")) {
+             rgb = rgb.substring(1);
+         }
+         int rgbInt = 0;
+         try {
+             rgbInt = Integer.parseInt(rgb, 16);
+         } catch (NumberFormatException e) {
+             return defaultColor;
+         }
+         Color color = new Color((rgbInt & 0xFF0000) >>> 16,
+             (rgbInt & 0x00FF00) >>> 8,
+             (rgbInt & 0x0000FF));
+         return color;
+     }
+     /**
+      * Get the number of millis to wait before switching the blink from
+      * visible to invisible.
+      *
+      * @return the number of milli to wait before switching the blink from
+      * visible to invisible
+      */
+     public long getBlinkMillis() {
+         return blinkMillis;
+     }
+     /**
+      * Get the current status of the blink flag.
+      *
+      * @return true if the cursor and blinking text should be visible
+      */
+     public boolean getCursorBlinkVisible() {
+         return cursorBlinkVisible;
+     }
+     /**
+      * Get the font size in points.
+      *
+      * @return font size in points
+      */
+     public int getFontSize() {
+         return fontSize;
+     }
+     /**
+      * Set the font size in points.
+      *
+      * @param fontSize font size in points
+      */
+     public void setFontSize(final int fontSize) {
+         this.fontSize = fontSize;
+         Font newFont = font.deriveFont((float) fontSize);
+         setFont(newFont);
+     }
+     /**
+      * Set to a new font, and resize the screen to match its dimensions.
+      *
+      * @param font the new font
+      */
+     public void setFont(final Font font) {
+         if (!SwingUtilities.isEventDispatchThread()) {
+             // Not in the Swing thread: force this inside the Swing thread.
+             try {
+                 SwingUtilities.invokeAndWait(new Runnable() {
+                     public void run() {
+                         synchronized (this) {
+                             SwingTerminal.this.font = font;
+                             getFontDimensions();
+                             swing.setFont(font);
+                             glyphCacheBlink = new HashMap<Cell, BufferedImage>();
+                             glyphCache = new HashMap<Cell, BufferedImage>();
+                             resizeToScreen(true);
+                         }
+                     }
+                 });
+             } catch (InterruptedException e) {
+                 e.printStackTrace();
+             } catch (java.lang.reflect.InvocationTargetException e) {
+                 e.printStackTrace();
+             }
+         } else {
+             synchronized (this) {
+                 SwingTerminal.this.font = font;
+                 getFontDimensions();
+                 swing.setFont(font);
+                 glyphCacheBlink = new HashMap<Cell, BufferedImage>();
+                 glyphCache = new HashMap<Cell, BufferedImage>();
+                 resizeToScreen(true);
+             }
+         }
+     }
+     /**
+      * Get the font this screen was last set to.
+      *
+      * @return the font
+      */
+     public Font getFont() {
+         return font;
+     }
+     /**
+      * Set the font to Terminus, the best all-around font for both CP437 and
+      * ISO8859-1.
+      */
+     public void setDefaultFont() {
+         try {
+             ClassLoader loader = Thread.currentThread().getContextClassLoader();
+             InputStream in = loader.getResourceAsStream(FONTFILE);
+             Font terminusRoot = Font.createFont(Font.TRUETYPE_FONT, in);
+             Font terminus = terminusRoot.deriveFont(Font.PLAIN, fontSize);
+             font = terminus;
+         } catch (java.awt.FontFormatException e) {
+             e.printStackTrace();
+             font = new Font(Font.MONOSPACED, Font.PLAIN, fontSize);
+         } catch (java.io.IOException e) {
+             e.printStackTrace();
+             font = new Font(Font.MONOSPACED, Font.PLAIN, fontSize);
+         }
+         setFont(font);
+     }
+     /**
+      * Get the X text adjustment.
+      *
+      * @return X text adjustment
+      */
+     public int getTextAdjustX() {
+         return textAdjustX;
+     }
+     /**
+      * Set the X text adjustment.
+      *
+      * @param textAdjustX the X text adjustment
+      */
+     public void setTextAdjustX(final int textAdjustX) {
+         synchronized (this) {
+             this.textAdjustX = textAdjustX;
+             glyphCacheBlink = new HashMap<Cell, BufferedImage>();
+             glyphCache = new HashMap<Cell, BufferedImage>();
+             clearPhysical();
+         }
+     }
+     /**
+      * Get the Y text adjustment.
+      *
+      * @return Y text adjustment
+      */
+     public int getTextAdjustY() {
+         return textAdjustY;
+     }
+     /**
+      * Set the Y text adjustment.
+      *
+      * @param textAdjustY the Y text adjustment
+      */
+     public void setTextAdjustY(final int textAdjustY) {
+         synchronized (this) {
+             this.textAdjustY = textAdjustY;
+             glyphCacheBlink = new HashMap<Cell, BufferedImage>();
+             glyphCache = new HashMap<Cell, BufferedImage>();
+             clearPhysical();
+         }
+     }
+     /**
+      * Get the height text adjustment.
+      *
+      * @return height text adjustment
+      */
+     public int getTextAdjustHeight() {
+         return textAdjustHeight;
+     }
+     /**
+      * Set the height text adjustment.
+      *
+      * @param textAdjustHeight the height text adjustment
+      */
+     public void setTextAdjustHeight(final int textAdjustHeight) {
+         synchronized (this) {
+             this.textAdjustHeight = textAdjustHeight;
+             textHeight = fontTextHeight + textAdjustHeight;
+             glyphCacheBlink = new HashMap<Cell, BufferedImage>();
+             glyphCache = new HashMap<Cell, BufferedImage>();
+             clearPhysical();
+         }
+     }
+     /**
+      * Get the width text adjustment.
+      *
+      * @return width text adjustment
+      */
+     public int getTextAdjustWidth() {
+         return textAdjustWidth;
+     }
+     /**
+      * Set the width text adjustment.
+      *
+      * @param textAdjustWidth the width text adjustment
+      */
+     public void setTextAdjustWidth(final int textAdjustWidth) {
+         synchronized (this) {
+             this.textAdjustWidth = textAdjustWidth;
+             textWidth = fontTextWidth + textAdjustWidth;
+             glyphCacheBlink = new HashMap<Cell, BufferedImage>();
+             glyphCache = new HashMap<Cell, BufferedImage>();
+             clearPhysical();
+         }
+     }
+     /**
+      * Convert a CellAttributes foreground color to an Swing Color.
+      *
+      * @param attr the text attributes
+      * @return the Swing Color
+      */
+     public static Color attrToForegroundColor(final CellAttributes attr) {
+         int rgb = attr.getForeColorRGB();
+         if (rgb >= 0) {
+             int red     = (rgb >> 16) & 0xFF;
+             int green   = (rgb >>  8) & 0xFF;
+             int blue    =  rgb        & 0xFF;
+             return new Color(red, green, blue);
+         }
+         if (attr.isBold()) {
+             if (attr.getForeColor().equals(jexer.bits.Color.BLACK)) {
+                 return MYBOLD_BLACK;
+             } else if (attr.getForeColor().equals(jexer.bits.Color.RED)) {
+                 return MYBOLD_RED;
+             } else if (attr.getForeColor().equals(jexer.bits.Color.BLUE)) {
+                 return MYBOLD_BLUE;
+             } else if (attr.getForeColor().equals(jexer.bits.Color.GREEN)) {
+                 return MYBOLD_GREEN;
+             } else if (attr.getForeColor().equals(jexer.bits.Color.YELLOW)) {
+                 return MYBOLD_YELLOW;
+             } else if (attr.getForeColor().equals(jexer.bits.Color.CYAN)) {
+                 return MYBOLD_CYAN;
+             } else if (attr.getForeColor().equals(jexer.bits.Color.MAGENTA)) {
+                 return MYBOLD_MAGENTA;
+             } else if (attr.getForeColor().equals(jexer.bits.Color.WHITE)) {
+                 return MYBOLD_WHITE;
+             }
+         } else {
+             if (attr.getForeColor().equals(jexer.bits.Color.BLACK)) {
+                 return MYBLACK;
+             } else if (attr.getForeColor().equals(jexer.bits.Color.RED)) {
+                 return MYRED;
+             } else if (attr.getForeColor().equals(jexer.bits.Color.BLUE)) {
+                 return MYBLUE;
+             } else if (attr.getForeColor().equals(jexer.bits.Color.GREEN)) {
+                 return MYGREEN;
+             } else if (attr.getForeColor().equals(jexer.bits.Color.YELLOW)) {
+                 return MYYELLOW;
+             } else if (attr.getForeColor().equals(jexer.bits.Color.CYAN)) {
+                 return MYCYAN;
+             } else if (attr.getForeColor().equals(jexer.bits.Color.MAGENTA)) {
+                 return MYMAGENTA;
+             } else if (attr.getForeColor().equals(jexer.bits.Color.WHITE)) {
+                 return MYWHITE;
+             }
+         }
+         throw new IllegalArgumentException("Invalid color: " +
+             attr.getForeColor().getValue());
+     }
+     /**
+      * Convert a CellAttributes background color to an Swing Color.
+      *
+      * @param attr the text attributes
+      * @return the Swing Color
+      */
+     public static Color attrToBackgroundColor(final CellAttributes attr) {
+         int rgb = attr.getBackColorRGB();
+         if (rgb >= 0) {
+             int red     = (rgb >> 16) & 0xFF;
+             int green   = (rgb >>  8) & 0xFF;
+             int blue    =  rgb        & 0xFF;
+             return new Color(red, green, blue);
+         }
+         if (attr.getBackColor().equals(jexer.bits.Color.BLACK)) {
+             return MYBLACK;
+         } else if (attr.getBackColor().equals(jexer.bits.Color.RED)) {
+             return MYRED;
+         } else if (attr.getBackColor().equals(jexer.bits.Color.BLUE)) {
+             return MYBLUE;
+         } else if (attr.getBackColor().equals(jexer.bits.Color.GREEN)) {
+             return MYGREEN;
+         } else if (attr.getBackColor().equals(jexer.bits.Color.YELLOW)) {
+             return MYYELLOW;
+         } else if (attr.getBackColor().equals(jexer.bits.Color.CYAN)) {
+             return MYCYAN;
+         } else if (attr.getBackColor().equals(jexer.bits.Color.MAGENTA)) {
+             return MYMAGENTA;
+         } else if (attr.getBackColor().equals(jexer.bits.Color.WHITE)) {
+             return MYWHITE;
+         }
+         throw new IllegalArgumentException("Invalid color: " +
+             attr.getBackColor().getValue());
+     }
+     /**
+      * Figure out what textAdjustX, textAdjustY, textAdjustHeight, and
+      * textAdjustWidth should be, based on the location of a vertical bar and
+      * a horizontal bar.
+      */
+     private void getFontAdjustments() {
+         BufferedImage image = null;
+         // What SHOULD happen is that the topmost/leftmost white pixel is at
+         // position (gr2x, gr2y).  But it might also be off by a pixel in
+         // either direction.
+         Graphics2D gr2 = null;
+         int gr2x = 3;
+         int gr2y = 3;
+         image = new BufferedImage(fontTextWidth * 2, fontTextHeight * 2,
+             BufferedImage.TYPE_INT_ARGB);
+         gr2 = image.createGraphics();
+         gr2.setFont(swing.getFont());
+         gr2.setColor(java.awt.Color.BLACK);
+         gr2.fillRect(0, 0, fontTextWidth * 2, fontTextHeight * 2);
+         gr2.setColor(java.awt.Color.WHITE);
+         char [] chars = new char[1];
+         chars[0] = jexer.bits.GraphicsChars.SINGLE_BAR;
+         gr2.drawChars(chars, 0, 1, gr2x, gr2y + fontTextHeight - maxDescent);
+         chars[0] = jexer.bits.GraphicsChars.VERTICAL_BAR;
+         gr2.drawChars(chars, 0, 1, gr2x, gr2y + fontTextHeight - maxDescent);
+         gr2.dispose();
+         int top = fontTextHeight * 2;
+         int bottom = -1;
+         int left = fontTextWidth * 2;
+         int right = -1;
+         textAdjustX = 0;
+         textAdjustY = 0;
+         textAdjustHeight = 0;
+         textAdjustWidth = 0;
+         for (int x = 0; x < fontTextWidth * 2; x++) {
+             for (int y = 0; y < fontTextHeight * 2; y++) {
+                 /*
+                 System.err.println("H X: " + x + " Y: " + y + " " +
+                     image.getRGB(x, y));
+                 */
+                 if ((image.getRGB(x, y) & 0xFFFFFF) != 0) {
+                     // Pixel is present.
+                     if (y < top) {
+                         top = y;
+                     }
+                     if (y > bottom) {
+                         bottom = y;
+                     }
+                     if (x < left) {
+                         left = x;
+                     }
+                     if (x > right) {
+                         right = x;
+                     }
+                 }
+             }
+         }
+         if (left < right) {
+             textAdjustX = (gr2x - left);
+             textAdjustWidth = fontTextWidth - (right - left + 1);
+         }
+         if (top < bottom) {
+             textAdjustY = (gr2y - top);
+             textAdjustHeight = fontTextHeight - (bottom - top + 1);
+         }
+         // System.err.println("top " + top + " bottom " + bottom);
+         // System.err.println("left " + left + " right " + right);
+         // Special case: do not believe fonts that claim to be wider than
+         // they are tall.
+         if (fontTextWidth >= fontTextHeight) {
+             textAdjustX = 0;
+             textAdjustWidth = 0;
+             fontTextWidth = fontTextHeight / 2;
+         }
+     }
+     /**
+      * Figure out my font dimensions.  This code path works OK for the JFrame
+      * case, and can be called immediately after JFrame creation.
+      */
+     private void getFontDimensions() {
+         swing.setFont(font);
+         Graphics gr = swing.getGraphics();
+         if (gr == null) {
+             return;
+         }
+         getFontDimensions(gr);
+     }
+     /**
+      * Figure out my font dimensions.  This code path is needed to lazy-load
+      * the information inside paint().
+      *
+      * @param gr Graphics object to use
+      */
+     private void getFontDimensions(final Graphics gr) {
+         swing.setFont(font);
+         FontMetrics fm = gr.getFontMetrics();
+         maxDescent = fm.getMaxDescent();
+         Rectangle2D bounds = fm.getMaxCharBounds(gr);
+         int leading = fm.getLeading();
+         fontTextWidth = (int)Math.round(bounds.getWidth());
+         // fontTextHeight = (int)Math.round(bounds.getHeight()) - maxDescent;
+         // This produces the same number, but works better for ugly
+         // monospace.
+         fontTextHeight = fm.getMaxAscent() + maxDescent - leading;
+         getFontAdjustments();
+         textHeight = fontTextHeight + textAdjustHeight;
+         textWidth = fontTextWidth + textAdjustWidth;
+         if (sessionInfo != null) {
+             sessionInfo.setTextCellDimensions(textWidth, textHeight);
+         }
+         gotFontDimensions = true;
+     }
+     /**
+      * Resize the physical screen to match the logical screen dimensions.
+      *
+      * @param resizeComponent if true, resize the Swing component
+      */
+     private void resizeToScreen(final boolean resizeComponent) {
+         if (resizeComponent) {
+             swing.setDimensions(textWidth * width, textHeight * height);
+         }
+         clearPhysical();
+     }
+     /**
+      * Resize the physical screen to match the logical screen dimensions.
+      */
+     @Override
+     public void resizeToScreen() {
+         resizeToScreen(false);
+     }
+     /**
+      * Draw one cell's image to the screen.
+      *
+      * @param gr the Swing Graphics context
+      * @param cell the Cell to draw
+      * @param xPixel the x-coordinate to render to.  0 means the
+      * left-most pixel column.
+      * @param yPixel the y-coordinate to render to.  0 means the top-most
+      * pixel row.
+      */
+     private void drawImage(final Graphics gr, final Cell cell,
+         final int xPixel, final int yPixel) {
+         /*
+         System.err.println("drawImage(): " + xPixel + " " + yPixel +
+             " " + cell);
+         */
+         // Draw the background rectangle, then the foreground character.
+         assert (cell.isImage());
+         gr.setColor(cell.getBackground());
+         gr.fillRect(xPixel, yPixel, textWidth, textHeight);
+         BufferedImage image = cell.getImage();
+         if (image != null) {
+             if (swing.getFrame() != null) {
+                 gr.drawImage(image, xPixel, yPixel, swing.getFrame());
+             } else {
+                 gr.drawImage(image, xPixel, yPixel, swing.getComponent());
+             }
+             return;
+         }
+     }
+     /**
+      * Draw one glyph to the screen.
+      *
+      * @param gr the Swing Graphics context
+      * @param cell the Cell to draw
+      * @param xPixel the x-coordinate to render to.  0 means the
+      * left-most pixel column.
+      * @param yPixel the y-coordinate to render to.  0 means the top-most
+      * pixel row.
+      */
+     private void drawGlyph(final Graphics gr, final Cell cell,
+         final int xPixel, final int yPixel) {
+         /*
+         System.err.println("drawGlyph(): " + xPixel + " " + yPixel +
+             " " + cell);
+          */
+         BufferedImage image = null;
+         if (cell.isBlink() && !cursorBlinkVisible) {
+             image = glyphCacheBlink.get(cell);
+         } else {
+             image = glyphCache.get(cell);
+         }
+         if (image != null) {
+             if (swing.getFrame() != null) {
+                 gr.drawImage(image, xPixel, yPixel, swing.getFrame());
+             } else {
+                 gr.drawImage(image, xPixel, yPixel, swing.getComponent());
+             }
+             return;
+         }
+         // Generate glyph and draw it.
+         Graphics2D gr2 = null;
+         int gr2x = xPixel;
+         int gr2y = yPixel;
+         if ((SwingComponent.tripleBuffer) && (swing.getFrame() != null)) {
+             image = new BufferedImage(textWidth, textHeight,
+                 BufferedImage.TYPE_INT_ARGB);
+             gr2 = image.createGraphics();
+             gr2.setFont(swing.getFont());
+             gr2x = 0;
+             gr2y = 0;
+         } else {
+             gr2 = (Graphics2D) gr;
+         }
+         Cell cellColor = new Cell(cell);
+         // Check for reverse
+         if (cell.isReverse()) {
+             cellColor.setForeColor(cell.getBackColor());
+             cellColor.setBackColor(cell.getForeColor());
+         }
+         // Draw the background rectangle, then the foreground character.
+         gr2.setColor(attrToBackgroundColor(cellColor));
+         gr2.fillRect(gr2x, gr2y, textWidth, textHeight);
+         // Handle blink and underline
+         if (!cell.isBlink()
+             || (cell.isBlink() && cursorBlinkVisible)
+         ) {
+             gr2.setColor(attrToForegroundColor(cellColor));
+             char [] chars = Character.toChars(cell.getChar());
+             gr2.drawChars(chars, 0, chars.length, gr2x + textAdjustX,
+                 gr2y + textHeight - maxDescent + textAdjustY);
+             if (cell.isUnderline()) {
+                 gr2.fillRect(gr2x, gr2y + textHeight - 2, textWidth, 2);
+             }
+         }
+         if ((SwingComponent.tripleBuffer) && (swing.getFrame() != null)) {
+             gr2.dispose();
+             // We need a new key that will not be mutated by
+             // invertCell().
+             Cell key = new Cell(cell);
+             if (cell.isBlink() && !cursorBlinkVisible) {
+                 glyphCacheBlink.put(key, image);
+             } else {
+                 glyphCache.put(key, image);
+             }
+             if (swing.getFrame() != null) {
+                 gr.drawImage(image, xPixel, yPixel, swing.getFrame());
+             } else {
+                 gr.drawImage(image, xPixel, yPixel, swing.getComponent());
+             }
+         }
+     }
+     /**
+      * Check if the cursor is visible, and if so draw it.
+      *
+      * @param gr the Swing Graphics context
+      */
+     private void drawCursor(final Graphics gr) {
+         if (cursorVisible
+             && (cursorY >= 0)
+             && (cursorX >= 0)
+             && (cursorY <= height - 1)
+             && (cursorX <= width - 1)
+             && cursorBlinkVisible
+         ) {
+             int xPixel = cursorX * textWidth + left;
+             int yPixel = cursorY * textHeight + top;
+             Cell lCell = logical[cursorX][cursorY];
+             int cursorWidth = textWidth;
+             switch (lCell.getWidth()) {
+             case SINGLE:
+                 // NOP
+                 break;
+             case LEFT:
+                 cursorWidth *= 2;
+                 break;
+             case RIGHT:
+                 cursorWidth *= 2;
+                 xPixel -= textWidth;
+                 break;
+             }
+             gr.setColor(attrToForegroundColor(lCell));
+             switch (cursorStyle) {
+             default:
+                 // Fall through...
+             case UNDERLINE:
+                 gr.fillRect(xPixel, yPixel + textHeight - 2, cursorWidth, 2);
+                 break;
+             case BLOCK:
+                 gr.fillRect(xPixel, yPixel, cursorWidth, textHeight);
+                 break;
+             case OUTLINE:
+                 gr.drawRect(xPixel, yPixel, cursorWidth - 1, textHeight - 1);
+                 break;
+             case VERTICAL_BAR:
+                 gr.fillRect(xPixel, yPixel, 2, textHeight);
+                 break;
+             }
+         }
+     }
+     /**
+      * Reset the blink timer.
+      */
+     private void resetBlinkTimer() {
+         lastBlinkTime = System.currentTimeMillis();
+         cursorBlinkVisible = true;
+     }
+     /**
+      * Paint redraws the whole screen.
+      *
+      * @param gr the Swing Graphics context
+      */
+     public void paint(final Graphics gr) {
+         if (gotFontDimensions == false) {
+             // Lazy-load the text width/height
+             getFontDimensions(gr);
+             /*
+             System.err.println("textWidth " + textWidth +
+                 " textHeight " + textHeight);
+             System.err.println("FONT: " + swing.getFont() + " font " + font);
+              */
+         }
+         if ((swing.getFrame() != null)
+             && (swing.getBufferStrategy() != null)
+             && (SwingUtilities.isEventDispatchThread())
+         ) {
+             // System.err.println("paint(), skip first paint on swing thread");
+             return;
+         }
+         int xCellMin = 0;
+         int xCellMax = width;
+         int yCellMin = 0;
+         int yCellMax = height;
+         Rectangle bounds = gr.getClipBounds();
+         if (bounds != null) {
+             // Only update what is in the bounds
+             xCellMin = textColumn(bounds.x);
+             xCellMax = textColumn(bounds.x + bounds.width) + 1;
+             if (xCellMax > width) {
+                 xCellMax = width;
+             }
+             if (xCellMin >= xCellMax) {
+                 xCellMin = xCellMax - 2;
+             }
+             if (xCellMin < 0) {
+                 xCellMin = 0;
+             }
+             yCellMin = textRow(bounds.y);
+             yCellMax = textRow(bounds.y + bounds.height) + 1;
+             if (yCellMax > height) {
+                 yCellMax = height;
+             }
+             if (yCellMin >= yCellMax) {
+                 yCellMin = yCellMax - 2;
+             }
+             if (yCellMin < 0) {
+                 yCellMin = 0;
+             }
+         } else {
+             // We need a total repaint
+             reallyCleared = true;
+         }
+         // Prevent updates to the screen's data from the TApplication
+         // threads.
+         synchronized (this) {
+             /*
+             System.err.printf("bounds %s X %d %d Y %d %d\n",
+                  bounds, xCellMin, xCellMax, yCellMin, yCellMax);
+              */
+             for (int y = yCellMin; y < yCellMax; y++) {
+                 for (int x = xCellMin; x < xCellMax; x++) {
+                     int xPixel = x * textWidth + left;
+                     int yPixel = y * textHeight + top;
+                     Cell lCell = logical[x][y];
+                     Cell pCell = physical[x][y];
+                     if (!lCell.equals(pCell)
+                         || lCell.isBlink()
+                         || reallyCleared
+                         || (swing.getFrame() == null)) {
+                         if (lCell.isImage()) {
+                             drawImage(gr, lCell, xPixel, yPixel);
+                         } else {
+                             drawGlyph(gr, lCell, xPixel, yPixel);
+                         }
+                         // Physical is always updated
+                         physical[x][y].setTo(lCell);
+                     }
+                 }
+             }
+             drawCursor(gr);
+             reallyCleared = false;
+         } // synchronized (this)
+     }
+     /**
+      * Restore terminal to normal state.
+      */
+     public void shutdown() {
+         swing.dispose();
+     }
+     /**
+      * Push the logical screen to the physical device.
+      */
+     private void drawToSwing() {
+         /*
+         System.err.printf("drawToSwing(): reallyCleared %s dirty %s\n",
+             reallyCleared, dirty);
+         */
+         // If reallyCleared is set, we have to draw everything.
+         if ((swing.getFrame() != null)
+             && (swing.getBufferStrategy() != null)
+             && (reallyCleared == true)
+         ) {
+             // Triple-buffering: we have to redraw everything on this thread.
+             Graphics gr = swing.getBufferStrategy().getDrawGraphics();
+             swing.paint(gr);
+             gr.dispose();
+             swing.getBufferStrategy().show();
+             Toolkit.getDefaultToolkit().sync();
+             return;
+         } else if (((swing.getFrame() != null)
+                 && (swing.getBufferStrategy() == null))
+             || (reallyCleared == true)
+         ) {
+             // Repaint everything on the Swing thread.
+             // System.err.println("REPAINT ALL");
+             swing.repaint();
+             return;
+         }
+         if ((swing.getFrame() != null) && (swing.getBufferStrategy() != null)) {
+             Graphics gr = swing.getBufferStrategy().getDrawGraphics();
+             synchronized (this) {
+                 for (int y = 0; y < height; y++) {
+                     for (int x = 0; x < width; x++) {
+                         Cell lCell = logical[x][y];
+                         Cell pCell = physical[x][y];
+                         int xPixel = x * textWidth + left;
+                         int yPixel = y * textHeight + top;
+                         if (!lCell.equals(pCell)
+                             || ((x == cursorX)
+                                 && (y == cursorY)
+                                 && cursorVisible)
+                             || (lCell.isBlink())
+                         ) {
+                             if (lCell.isImage()) {
+                                 drawImage(gr, lCell, xPixel, yPixel);
+                             } else {
+                                 drawGlyph(gr, lCell, xPixel, yPixel);
+                             }
+                             physical[x][y].setTo(lCell);
+                         }
+                     }
+                 }
+                 drawCursor(gr);
+             } // synchronized (this)
+             gr.dispose();
+             swing.getBufferStrategy().show();
+             Toolkit.getDefaultToolkit().sync();
+             return;
+         }
+         // Swing thread version: request a repaint, but limit it to the area
+         // that has changed.
+         // Find the minimum-size damaged region.
+         int xMin = swing.getWidth();
+         int xMax = 0;
+         int yMin = swing.getHeight();
+         int yMax = 0;
+         synchronized (this) {
+             for (int y = 0; y < height; y++) {
+                 for (int x = 0; x < width; x++) {
+                     Cell lCell = logical[x][y];
+                     Cell pCell = physical[x][y];
+                     int xPixel = x * textWidth + left;
+                     int yPixel = y * textHeight + top;
+                     if (!lCell.equals(pCell)
+                         || ((x == cursorX)
+                             && (y == cursorY)
+                             && cursorVisible)
+                         || lCell.isBlink()
+                     ) {
+                         if (xPixel < xMin) {
+                             xMin = xPixel;
+                         }
+                         if (xPixel + textWidth > xMax) {
+                             xMax = xPixel + textWidth;
+                         }
+                         if (yPixel < yMin) {
+                             yMin = yPixel;
+                         }
+                         if (yPixel + textHeight > yMax) {
+                             yMax = yPixel + textHeight;
+                         }
+                     }
+                 }
+             }
+         }
+         if (xMin + textWidth >= xMax) {
+             xMax += textWidth;
+         }
+         if (yMin + textHeight >= yMax) {
+             yMax += textHeight;
+         }
+         // Repaint the desired area
+         /*
+         System.err.printf("REPAINT X %d %d Y %d %d\n", xMin, xMax,
+             yMin, yMax);
+         */
+         if ((swing.getFrame() != null) && (swing.getBufferStrategy() != null)) {
+             // This path should never be taken, but is left here for
+             // completeness.
+             Graphics gr = swing.getBufferStrategy().getDrawGraphics();
+             Rectangle bounds = new Rectangle(xMin, yMin, xMax - xMin,
+                 yMax - yMin);
+             gr.setClip(bounds);
+             swing.paint(gr);
+             gr.dispose();
+             swing.getBufferStrategy().show();
+             Toolkit.getDefaultToolkit().sync();
+         } else {
+             // Repaint on the Swing thread.
+             swing.repaint(xMin, yMin, xMax - xMin, yMax - yMin);
+         }
+     }
+     /**
+      * Convert pixel column position to text cell column position.
+      *
+      * @param x pixel column position
+      * @return text cell column position
+      */
+     public int textColumn(final int x) {
+         int column = ((x - left) / textWidth);
+         if (column < 0) {
+             column = 0;
+         }
+         if (column > width - 1) {
+             column = width - 1;
+         }
+         return column;
+     }
+     /**
+      * Convert pixel row position to text cell row position.
+      *
+      * @param y pixel row position
+      * @return text cell row position
+      */
+     public int textRow(final int y) {
+         int row = ((y - top) / textHeight);
+         if (row < 0) {
+             row = 0;
+         }
+         if (row > height - 1) {
+             row = height - 1;
+         }
+         return row;
+     }
+     /**
+      * Getter for sessionInfo.
+      *
+      * @return the SessionInfo
+      */
+     public SessionInfo getSessionInfo() {
+         return sessionInfo;
+     }
+     /**
+      * Getter for the underlying Swing component.
+      *
+      * @return the SwingComponent
+      */
+     public SwingComponent getSwingComponent() {
+         return swing;
+     }
+     // ------------------------------------------------------------------------
+     // KeyListener ------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Pass Swing keystrokes into the event queue.
+      *
+      * @param key keystroke received
+      */
+     public void keyReleased(final KeyEvent key) {
+         // Ignore release events
+     }
+     /**
+      * Pass Swing keystrokes into the event queue.
+      *
+      * @param key keystroke received
+      */
+     public void keyTyped(final KeyEvent key) {
+         // Ignore typed events
+     }
+     /**
+      * Pass Swing keystrokes into the event queue.
+      *
+      * @param key keystroke received
+      */
+     public void keyPressed(final KeyEvent key) {
+         boolean alt = false;
+         boolean shift = false;
+         boolean ctrl = false;
+         char ch = ' ';
+         boolean isKey = false;
+         if (key.isActionKey()) {
+             isKey = true;
+         } else {
+             ch = key.getKeyChar();
+         }
+         alt = key.isAltDown();
+         ctrl = key.isControlDown();
+         shift = key.isShiftDown();
+         /*
+         System.err.printf("Swing Key: %s\n", key);
+         System.err.printf("   isKey: %s\n", isKey);
+         System.err.printf("   alt: %s\n", alt);
+         System.err.printf("   ctrl: %s\n", ctrl);
+         System.err.printf("   shift: %s\n", shift);
+         System.err.printf("   ch: %s\n", ch);
+         */
+         // Special case: not return the bare modifier presses
+         switch (key.getKeyCode()) {
+         case KeyEvent.VK_ALT:
+             return;
+         case KeyEvent.VK_ALT_GRAPH:
+             return;
+         case KeyEvent.VK_CONTROL:
+             return;
+         case KeyEvent.VK_SHIFT:
+             return;
+         case KeyEvent.VK_META:
+             return;
+         default:
+             break;
+         }
+         TKeypress keypress = null;
+         if (isKey) {
+             switch (key.getKeyCode()) {
+             case KeyEvent.VK_F1:
+                 keypress = new TKeypress(true, TKeypress.F1, ' ',
+                     alt, ctrl, shift);
+                 break;
+             case KeyEvent.VK_F2:
+                 keypress = new TKeypress(true, TKeypress.F2, ' ',
+                     alt, ctrl, shift);
+                 break;
+             case KeyEvent.VK_F3:
+                 keypress = new TKeypress(true, TKeypress.F3, ' ',
+                     alt, ctrl, shift);
+                 break;
+             case KeyEvent.VK_F4:
+                 keypress = new TKeypress(true, TKeypress.F4, ' ',
+                     alt, ctrl, shift);
+                 break;
+             case KeyEvent.VK_F5:
+                 keypress = new TKeypress(true, TKeypress.F5, ' ',
+                     alt, ctrl, shift);
+                 break;
+             case KeyEvent.VK_F6:
+                 keypress = new TKeypress(true, TKeypress.F6, ' ',
+                     alt, ctrl, shift);
+                 break;
+             case KeyEvent.VK_F7:
+                 keypress = new TKeypress(true, TKeypress.F7, ' ',
+                     alt, ctrl, shift);
+                 break;
+             case KeyEvent.VK_F8:
+                 keypress = new TKeypress(true, TKeypress.F8, ' ',
+                     alt, ctrl, shift);
+                 break;
+             case KeyEvent.VK_F9:
+                 keypress = new TKeypress(true, TKeypress.F9, ' ',
+                     alt, ctrl, shift);
+                 break;
+             case KeyEvent.VK_F10:
+                 keypress = new TKeypress(true, TKeypress.F10, ' ',
+                     alt, ctrl, shift);
+                 break;
+             case KeyEvent.VK_F11:
+                 keypress = new TKeypress(true, TKeypress.F11, ' ',
+                     alt, ctrl, shift);
+                 break;
+             case KeyEvent.VK_F12:
+                 keypress = new TKeypress(true, TKeypress.F12, ' ',
+                     alt, ctrl, shift);
+                 break;
+             case KeyEvent.VK_HOME:
+                 keypress = new TKeypress(true, TKeypress.HOME, ' ',
+                     alt, ctrl, shift);
+                 break;
+             case KeyEvent.VK_END:
+                 keypress = new TKeypress(true, TKeypress.END, ' ',
+                     alt, ctrl, shift);
+                 break;
+             case KeyEvent.VK_PAGE_UP:
+                 keypress = new TKeypress(true, TKeypress.PGUP, ' ',
+                     alt, ctrl, shift);
+                 break;
+             case KeyEvent.VK_PAGE_DOWN:
+                 keypress = new TKeypress(true, TKeypress.PGDN, ' ',
+                     alt, ctrl, shift);
+                 break;
+             case KeyEvent.VK_INSERT:
+                 keypress = new TKeypress(true, TKeypress.INS, ' ',
+                     alt, ctrl, shift);
+                 break;
+             case KeyEvent.VK_DELETE:
+                 keypress = new TKeypress(true, TKeypress.DEL, ' ',
+                     alt, ctrl, shift);
+                 break;
+             case KeyEvent.VK_RIGHT:
+                 keypress = new TKeypress(true, TKeypress.RIGHT, ' ',
+                     alt, ctrl, shift);
+                 break;
+             case KeyEvent.VK_LEFT:
+                 keypress = new TKeypress(true, TKeypress.LEFT, ' ',
+                     alt, ctrl, shift);
+                 break;
+             case KeyEvent.VK_UP:
+                 keypress = new TKeypress(true, TKeypress.UP, ' ',
+                     alt, ctrl, shift);
+                 break;
+             case KeyEvent.VK_DOWN:
+                 keypress = new TKeypress(true, TKeypress.DOWN, ' ',
+                     alt, ctrl, shift);
+                 break;
+             case KeyEvent.VK_TAB:
+                 // Special case: distinguish TAB vs BTAB
+                 if (shift) {
+                     keypress = kbShiftTab;
+                 } else {
+                     keypress = kbTab;
+                 }
+                 break;
+             case KeyEvent.VK_ENTER:
+                 keypress = new TKeypress(true, TKeypress.ENTER, ' ',
+                     alt, ctrl, shift);
+                 break;
+             case KeyEvent.VK_ESCAPE:
+                 keypress = new TKeypress(true, TKeypress.ESC, ' ',
+                     alt, ctrl, shift);
+                 break;
+             case KeyEvent.VK_BACK_SPACE:
+                 keypress = kbBackspace;
+                 break;
+             default:
+                 // Unsupported, ignore
+                 return;
+             }
+         }
+         if (keypress == null) {
+             switch (ch) {
+             case 0x08:
+                 // Disambiguate ^H from Backspace.
+                 if (KeyEvent.getKeyText(key.getKeyCode()).equals("H")) {
+                     // This is ^H.
+                     keypress = kbBackspace;
+                 } else {
+                     // We are emulating Xterm here, where the backspace key
+                     // on the keyboard returns ^?.
+                     keypress = kbBackspaceDel;
+                 }
+                 break;
+             case 0x0A:
+                 keypress = kbEnter;
+                 break;
+             case 0x1B:
+                 keypress = kbEsc;
+                 break;
+             case 0x0D:
+                 keypress = kbEnter;
+                 break;
+             case 0x09:
+                 if (shift) {
+                     keypress = kbShiftTab;
+                 } else {
+                     keypress = kbTab;
+                 }
+                 break;
+             case 0x7F:
+                 keypress = kbDel;
+                 break;
+             default:
+                 if (!alt && ctrl && !shift) {
+                     // Control character, replace ch with 'A', 'B', etc.
+                     ch = KeyEvent.getKeyText(key.getKeyCode()).charAt(0);
+                 }
+                 // Not a special key, put it together
+                 keypress = new TKeypress(false, 0, ch, alt, ctrl, shift);
+             }
+         }
+         // Save it and we are done.
+         synchronized (eventQueue) {
+             eventQueue.add(new TKeypressEvent(keypress));
+             resetBlinkTimer();
+         }
+         if (listener != null) {
+             synchronized (listener) {
+                 listener.notifyAll();
+             }
+         }
+     }
+     // ------------------------------------------------------------------------
+     // WindowListener ---------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Pass window events into the event queue.
+      *
+      * @param event window event received
+      */
+     public void windowActivated(final WindowEvent event) {
+         // Force a total repaint
+         synchronized (this) {
+             clearPhysical();
+         }
+     }
+     /**
+      * Pass window events into the event queue.
+      *
+      * @param event window event received
+      */
+     public void windowClosed(final WindowEvent event) {
+         // Ignore
+     }
+     /**
+      * Pass window events into the event queue.
+      *
+      * @param event window event received
+      */
+     public void windowClosing(final WindowEvent event) {
+         // Drop a cmBackendDisconnect and walk away
+         synchronized (eventQueue) {
+             eventQueue.add(new TCommandEvent(cmBackendDisconnect));
+             resetBlinkTimer();
+         }
+         if (listener != null) {
+             synchronized (listener) {
+                 listener.notifyAll();
+             }
+         }
+     }
+     /**
+      * Pass window events into the event queue.
+      *
+      * @param event window event received
+      */
+     public void windowDeactivated(final WindowEvent event) {
+         // Ignore
+     }
+     /**
+      * Pass window events into the event queue.
+      *
+      * @param event window event received
+      */
+     public void windowDeiconified(final WindowEvent event) {
+         // Ignore
+     }
+     /**
+      * Pass window events into the event queue.
+      *
+      * @param event window event received
+      */
+     public void windowIconified(final WindowEvent event) {
+         // Ignore
+     }
+     /**
+      * Pass window events into the event queue.
+      *
+      * @param event window event received
+      */
+     public void windowOpened(final WindowEvent event) {
+         // Ignore
+     }
+     // ------------------------------------------------------------------------
+     // ComponentListener ------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Pass component events into the event queue.
+      *
+      * @param event component event received
+      */
+     public void componentHidden(final ComponentEvent event) {
+         // Ignore
+     }
+     /**
+      * Pass component events into the event queue.
+      *
+      * @param event component event received
+      */
+     public void componentShown(final ComponentEvent event) {
+         // Ignore
+     }
+     /**
+      * Pass component events into the event queue.
+      *
+      * @param event component event received
+      */
+     public void componentMoved(final ComponentEvent event) {
+         // Ignore
+     }
+     /**
+      * Pass component events into the event queue.
+      *
+      * @param event component event received
+      */
+     public void componentResized(final ComponentEvent event) {
+         if (gotFontDimensions == false) {
+             // We are still waiting to get font information.  Don't pass a
+             // resize event up.
+             // System.err.println("size " + swing.getComponent().getSize());
+             return;
+         }
+         if (sessionInfo == null) {
+             // This is the initial component resize in construction, bail
+             // out.
+             return;
+         }
+         // Drop a new TResizeEvent into the queue
+         sessionInfo.queryWindowSize();
+         synchronized (eventQueue) {
+             TResizeEvent windowResize = new TResizeEvent(TResizeEvent.Type.SCREEN,
+                 sessionInfo.getWindowWidth(), sessionInfo.getWindowHeight());
+             eventQueue.add(windowResize);
+             resetBlinkTimer();
+             /*
+             System.err.println("Add resize event: " + windowResize.getWidth() +
+                 " x " + windowResize.getHeight());
+              */
+         }
+         if (listener != null) {
+             synchronized (listener) {
+                 listener.notifyAll();
+             }
+         }
+     }
+     // ------------------------------------------------------------------------
+     // MouseMotionListener ----------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Pass mouse events into the event queue.
+      *
+      * @param mouse mouse event received
+      */
+     public void mouseDragged(final MouseEvent mouse) {
+         int modifiers = mouse.getModifiersEx();
+         boolean eventMouse1 = false;
+         boolean eventMouse2 = false;
+         boolean eventMouse3 = false;
+         if ((modifiers & MouseEvent.BUTTON1_DOWN_MASK) != 0) {
+             eventMouse1 = true;
+         }
+         if ((modifiers & MouseEvent.BUTTON2_DOWN_MASK) != 0) {
+             eventMouse2 = true;
+         }
+         if ((modifiers & MouseEvent.BUTTON3_DOWN_MASK) != 0) {
+             eventMouse3 = true;
+         }
+         mouse1 = eventMouse1;
+         mouse2 = eventMouse2;
+         mouse3 = eventMouse3;
+         int x = textColumn(mouse.getX());
+         int y = textRow(mouse.getY());
+         TMouseEvent mouseEvent = new TMouseEvent(TMouseEvent.Type.MOUSE_MOTION,
+             x, y, x, y, mouse1, mouse2, mouse3, false, false);
+         synchronized (eventQueue) {
+             eventQueue.add(mouseEvent);
+             resetBlinkTimer();
+         }
+         if (listener != null) {
+             synchronized (listener) {
+                 listener.notifyAll();
+             }
+         }
+     }
+     /**
+      * Pass mouse events into the event queue.
+      *
+      * @param mouse mouse event received
+      */
+     public void mouseMoved(final MouseEvent mouse) {
+         int x = textColumn(mouse.getX());
+         int y = textRow(mouse.getY());
+         if ((x == oldMouseX) && (y == oldMouseY)) {
+             // Bail out, we've moved some pixels but not a whole text cell.
+             return;
+         }
+         oldMouseX = x;
+         oldMouseY = y;
+         TMouseEvent mouseEvent = new TMouseEvent(TMouseEvent.Type.MOUSE_MOTION,
+             x, y, x, y, mouse1, mouse2, mouse3, false, false);
+         synchronized (eventQueue) {
+             eventQueue.add(mouseEvent);
+             resetBlinkTimer();
+         }
+         if (listener != null) {
+             synchronized (listener) {
+                 listener.notifyAll();
+             }
+         }
+     }
+     // ------------------------------------------------------------------------
+     // MouseListener ----------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Pass mouse events into the event queue.
+      *
+      * @param mouse mouse event received
+      */
+     public void mouseClicked(final MouseEvent mouse) {
+         // Ignore
+     }
+     /**
+      * Pass mouse events into the event queue.
+      *
+      * @param mouse mouse event received
+      */
+     public void mouseEntered(final MouseEvent mouse) {
+         swing.requestFocusInWindow();
+     }
+     /**
+      * Pass mouse events into the event queue.
+      *
+      * @param mouse mouse event received
+      */
+     public void mouseExited(final MouseEvent mouse) {
+         // Ignore
+     }
+     /**
+      * Pass mouse events into the event queue.
+      *
+      * @param mouse mouse event received
+      */
+     public void mousePressed(final MouseEvent mouse) {
+         int modifiers = mouse.getModifiersEx();
+         boolean eventMouse1 = false;
+         boolean eventMouse2 = false;
+         boolean eventMouse3 = false;
+         if ((modifiers & MouseEvent.BUTTON1_DOWN_MASK) != 0) {
+             eventMouse1 = true;
+         }
+         if ((modifiers & MouseEvent.BUTTON2_DOWN_MASK) != 0) {
+             eventMouse2 = true;
+         }
+         if ((modifiers & MouseEvent.BUTTON3_DOWN_MASK) != 0) {
+             eventMouse3 = true;
+         }
+         mouse1 = eventMouse1;
+         mouse2 = eventMouse2;
+         mouse3 = eventMouse3;
+         int x = textColumn(mouse.getX());
+         int y = textRow(mouse.getY());
+         TMouseEvent mouseEvent = new TMouseEvent(TMouseEvent.Type.MOUSE_DOWN,
+             x, y, x, y, mouse1, mouse2, mouse3, false, false);
+         synchronized (eventQueue) {
+             eventQueue.add(mouseEvent);
+             resetBlinkTimer();
+         }
+         if (listener != null) {
+             synchronized (listener) {
+                 listener.notifyAll();
+             }
+         }
+     }
+     /**
+      * Pass mouse events into the event queue.
+      *
+      * @param mouse mouse event received
+      */
+     public void mouseReleased(final MouseEvent mouse) {
+         int modifiers = mouse.getModifiersEx();
+         boolean eventMouse1 = false;
+         boolean eventMouse2 = false;
+         boolean eventMouse3 = false;
+         if ((modifiers & MouseEvent.BUTTON1_DOWN_MASK) != 0) {
+             eventMouse1 = true;
+         }
+         if ((modifiers & MouseEvent.BUTTON2_DOWN_MASK) != 0) {
+             eventMouse2 = true;
+         }
+         if ((modifiers & MouseEvent.BUTTON3_DOWN_MASK) != 0) {
+             eventMouse3 = true;
+         }
+         if (mouse1) {
+             mouse1 = false;
+             eventMouse1 = true;
+         }
+         if (mouse2) {
+             mouse2 = false;
+             eventMouse2 = true;
+         }
+         if (mouse3) {
+             mouse3 = false;
+             eventMouse3 = true;
+         }
+         int x = textColumn(mouse.getX());
+         int y = textRow(mouse.getY());
+         TMouseEvent mouseEvent = new TMouseEvent(TMouseEvent.Type.MOUSE_UP,
+             x, y, x, y, eventMouse1, eventMouse2, eventMouse3, false, false);
+         synchronized (eventQueue) {
+             eventQueue.add(mouseEvent);
+             resetBlinkTimer();
+         }
+         if (listener != null) {
+             synchronized (listener) {
+                 listener.notifyAll();
+             }
+         }
+     }
+     // ------------------------------------------------------------------------
+     // MouseWheelListener -----------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Pass mouse events into the event queue.
+      *
+      * @param mouse mouse event received
+      */
+     public void mouseWheelMoved(final MouseWheelEvent mouse) {
+         int modifiers = mouse.getModifiersEx();
+         boolean eventMouse1 = false;
+         boolean eventMouse2 = false;
+         boolean eventMouse3 = false;
+         boolean mouseWheelUp = false;
+         boolean mouseWheelDown = false;
+         if ((modifiers & MouseEvent.BUTTON1_DOWN_MASK) != 0) {
+             eventMouse1 = true;
+         }
+         if ((modifiers & MouseEvent.BUTTON2_DOWN_MASK) != 0) {
+             eventMouse2 = true;
+         }
+         if ((modifiers & MouseEvent.BUTTON3_DOWN_MASK) != 0) {
+             eventMouse3 = true;
+         }
+         mouse1 = eventMouse1;
+         mouse2 = eventMouse2;
+         mouse3 = eventMouse3;
+         int x = textColumn(mouse.getX());
+         int y = textRow(mouse.getY());
+         if (mouse.getWheelRotation() > 0) {
+             mouseWheelDown = true;
+         }
+         if (mouse.getWheelRotation() < 0) {
+             mouseWheelUp = true;
+         }
+         TMouseEvent mouseEvent = new TMouseEvent(TMouseEvent.Type.MOUSE_DOWN,
+             x, y, x, y, mouse1, mouse2, mouse3, mouseWheelUp, mouseWheelDown);
+         synchronized (eventQueue) {
+             eventQueue.add(mouseEvent);
+             resetBlinkTimer();
+         }
+         if (listener != null) {
+             synchronized (listener) {
+                 listener.notifyAll();
+             }
+         }
+     }
+ }
index 0000000000000000000000000000000000000000,ccddce4c9ecc87059b4b7999e5d32af2e85db12e..ccddce4c9ecc87059b4b7999e5d32af2e85db12e
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,148 +1,148 @@@
+ /*
+  * Jexer - Java Text User Interface
+  *
+  * The MIT License (MIT)
+  *
+  * 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"),
+  * to deal in the Software without restriction, including without limitation
+  * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+  * and/or sell copies of the Software, and to permit persons to whom the
+  * Software is furnished to do so, subject to the following conditions:
+  *
+  * The above copyright notice and this permission notice shall be included in
+  * all copies or substantial portions of the Software.
+  *
+  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+  * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+  * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+  * DEALINGS IN THE SOFTWARE.
+  *
+  * @author Kevin Lamonte [kevin.lamonte@gmail.com]
+  * @version 1
+  */
+ package jexer.backend;
+ /**
+  * TSessionInfo provides a default session implementation.  The username is
+  * blank, language is "en_US", with a 80x24 text window.
+  */
+ public class TSessionInfo implements SessionInfo {
+     // ------------------------------------------------------------------------
+     // Variables --------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * User name.
+      */
+     private String username = "";
+     /**
+      * Language.
+      */
+     private String language = "en_US";
+     /**
+      * Text window width.
+      */
+     private int windowWidth = 80;
+     /**
+      * Text window height.
+      */
+     private int windowHeight = 24;
+     // ------------------------------------------------------------------------
+     // Constructors -----------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Public constructor.
+      */
+     public TSessionInfo() {
+         this(80, 24);
+     }
+     /**
+      * Public constructor.
+      *
+      * @param width the number of columns
+      * @param height the number of rows
+      */
+     public TSessionInfo(final int width, final int height) {
+         this.windowWidth        = width;
+         this.windowHeight       = height;
+     }
+     // ------------------------------------------------------------------------
+     // SessionInfo ------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Username getter.
+      *
+      * @return the username
+      */
+     public String getUsername() {
+         return this.username;
+     }
+     /**
+      * Username setter.
+      *
+      * @param username the value
+      */
+     public void setUsername(final String username) {
+         this.username = username;
+     }
+     /**
+      * Language getter.
+      *
+      * @return the language
+      */
+     public String getLanguage() {
+         return this.language;
+     }
+     /**
+      * Language setter.
+      *
+      * @param language the value
+      */
+     public void setLanguage(final String language) {
+         this.language = language;
+     }
+     /**
+      * Text window width getter.
+      *
+      * @return the window width
+      */
+     public int getWindowWidth() {
+         return windowWidth;
+     }
+     /**
+      * Text window height getter.
+      *
+      * @return the window height
+      */
+     public int getWindowHeight() {
+         return windowHeight;
+     }
+     /**
+      * Re-query the text window size.
+      */
+     public void queryWindowSize() {
+         // NOP
+     }
+ }
index 0000000000000000000000000000000000000000,d7f5bc8e246d32da560da62c7e02886e7ddf1971..d7f5bc8e246d32da560da62c7e02886e7ddf1971
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,228 +1,228 @@@
+ /*
+  * Jexer - Java Text User Interface
+  *
+  * The MIT License (MIT)
+  *
+  * 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"),
+  * to deal in the Software without restriction, including without limitation
+  * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+  * and/or sell copies of the Software, and to permit persons to whom the
+  * Software is furnished to do so, subject to the following conditions:
+  *
+  * The above copyright notice and this permission notice shall be included in
+  * all copies or substantial portions of the Software.
+  *
+  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+  * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+  * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+  * DEALINGS IN THE SOFTWARE.
+  *
+  * @author Kevin Lamonte [kevin.lamonte@gmail.com]
+  * @version 1
+  */
+ package jexer.backend;
+ import java.io.BufferedReader;
+ import java.io.InputStreamReader;
+ import java.io.IOException;
+ import java.util.StringTokenizer;
+ /**
+  * TTYSessionInfo queries environment variables and the tty window size for
+  * the session information.  The username is taken from user.name, language
+  * is taken from user.language, and text window size from 'stty size'.
+  */
+ public class TTYSessionInfo implements SessionInfo {
+     // ------------------------------------------------------------------------
+     // Variables --------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * User name.
+      */
+     private String username = "";
+     /**
+      * Language.
+      */
+     private String language = "";
+     /**
+      * Text window width.  Default is 80x24 (same as VT100-ish terminals).
+      */
+     private int windowWidth = 80;
+     /**
+      * Text window height.  Default is 80x24 (same as VT100-ish terminals).
+      */
+     private int windowHeight = 24;
+     /**
+      * Time at which the window size was refreshed.
+      */
+     private long lastQueryWindowTime;
+     // ------------------------------------------------------------------------
+     // Constructors -----------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Public constructor.
+      */
+     public TTYSessionInfo() {
+         // Populate lang and user from the environment
+         username = System.getProperty("user.name");
+         language = System.getProperty("user.language");
+         queryWindowSize();
+     }
+     // ------------------------------------------------------------------------
+     // SessionInfo ------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Username getter.
+      *
+      * @return the username
+      */
+     public String getUsername() {
+         return this.username;
+     }
+     /**
+      * Username setter.
+      *
+      * @param username the value
+      */
+     public void setUsername(final String username) {
+         this.username = username;
+     }
+     /**
+      * Language getter.
+      *
+      * @return the language
+      */
+     public String getLanguage() {
+         return this.language;
+     }
+     /**
+      * Language setter.
+      *
+      * @param language the value
+      */
+     public void setLanguage(final String language) {
+         this.language = language;
+     }
+     /**
+      * Text window width getter.
+      *
+      * @return the window width
+      */
+     public int getWindowWidth() {
+         if (System.getProperty("os.name").startsWith("Windows")) {
+             // Always use 80x25 for Windows (same as DOS)
+             return 80;
+         }
+         return windowWidth;
+     }
+     /**
+      * Text window height getter.
+      *
+      * @return the window height
+      */
+     public int getWindowHeight() {
+         if (System.getProperty("os.name").startsWith("Windows")) {
+             // Always use 80x25 for Windows (same as DOS)
+             return 25;
+         }
+         return windowHeight;
+     }
+     /**
+      * Re-query the text window size.
+      */
+     public void queryWindowSize() {
+         if (lastQueryWindowTime == 0) {
+             lastQueryWindowTime = System.currentTimeMillis();
+         } else {
+             long nowTime = System.currentTimeMillis();
+             if (nowTime - lastQueryWindowTime < 1000) {
+                 // Don't re-spawn stty if it hasn't been a full second since
+                 // the last time.
+                 return;
+             }
+         }
+         if (System.getProperty("os.name").startsWith("Linux")
+             || System.getProperty("os.name").startsWith("Mac OS X")
+             || System.getProperty("os.name").startsWith("SunOS")
+             || System.getProperty("os.name").startsWith("FreeBSD")
+         ) {
+             // Use stty to get the window size
+             sttyWindowSize();
+         }
+     }
+     // ------------------------------------------------------------------------
+     // TTYSessionInfo ---------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Call 'stty size' to obtain the tty window size.  windowWidth and
+      * windowHeight are set automatically.
+      */
+     private void sttyWindowSize() {
+         String [] cmd = {
+             "/bin/sh", "-c", "stty size < /dev/tty"
+         };
+         try {
+             Process process = Runtime.getRuntime().exec(cmd);
+             BufferedReader in = new BufferedReader(
+                 new InputStreamReader(process.getInputStream(), "UTF-8"));
+             String line = in.readLine();
+             if ((line != null) && (line.length() > 0)) {
+                 StringTokenizer tokenizer = new StringTokenizer(line);
+                 int rc = Integer.parseInt(tokenizer.nextToken());
+                 if (rc > 0) {
+                     windowHeight = rc;
+                 }
+                 rc = Integer.parseInt(tokenizer.nextToken());
+                 if (rc > 0) {
+                     windowWidth = rc;
+                 }
+             }
+             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) {
+                     // SQUASH
+                 }
+             }
+             int rc = process.exitValue();
+             if (rc != 0) {
+                 System.err.println("stty returned error code: " + rc);
+             }
+         } catch (IOException e) {
+             e.printStackTrace();
+         }
+     }
+ }
index 0000000000000000000000000000000000000000,f644b76ba4bf3d9e1642502ae330fc97146d1a5d..f644b76ba4bf3d9e1642502ae330fc97146d1a5d
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,543 +1,543 @@@
+ /*
+  * Jexer - Java Text User Interface
+  *
+  * The MIT License (MIT)
+  *
+  * 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"),
+  * to deal in the Software without restriction, including without limitation
+  * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+  * and/or sell copies of the Software, and to permit persons to whom the
+  * Software is furnished to do so, subject to the following conditions:
+  *
+  * The above copyright notice and this permission notice shall be included in
+  * all copies or substantial portions of the Software.
+  *
+  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+  * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+  * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+  * DEALINGS IN THE SOFTWARE.
+  *
+  * @author Kevin Lamonte [kevin.lamonte@gmail.com]
+  * @version 1
+  */
+ package jexer.backend;
+ import java.util.ArrayList;
+ import java.util.List;
+ import jexer.TApplication;
+ import jexer.TWindow;
+ import jexer.event.TCommandEvent;
+ import jexer.event.TInputEvent;
+ import jexer.event.TKeypressEvent;
+ import jexer.event.TMouseEvent;
+ import jexer.event.TResizeEvent;
+ import static jexer.TCommand.*;
+ /**
+  * TWindowBackend uses a window in one TApplication to provide a backend for
+  * another TApplication.
+  *
+  * Note that TWindow has its own getScreen() and setTitle() functions.
+  * Clients in TWindowBackend's application won't be able to use it to get at
+  * the other application's screen.  getOtherScreen() has been provided.
+  */
+ public class TWindowBackend extends TWindow implements Backend {
+     // ------------------------------------------------------------------------
+     // Variables --------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * The listening object that run() wakes up on new input.
+      */
+     private Object listener;
+     /**
+      * The object to sync on in draw().  This is normally otherScreen, but it
+      * could also be a MultiScreen.
+      */
+     private Object drawLock;
+     /**
+      * The event queue, filled up by a thread reading on input.
+      */
+     private List<TInputEvent> eventQueue;
+     /**
+      * The screen this window is monitoring.
+      */
+     private Screen otherScreen;
+     /**
+      * The application associated with otherScreen.
+      */
+     private TApplication otherApplication;
+     /**
+      * The session information.
+      */
+     private SessionInfo sessionInfo;
+     /**
+      * OtherScreen provides a hook to notify TWindowBackend of screen size
+      * changes.
+      */
+     private class OtherScreen extends LogicalScreen {
+         /**
+          * The TWindowBackend to notify.
+          */
+         private TWindowBackend window;
+         /**
+          * Public constructor.
+          */
+         public OtherScreen(final TWindowBackend window) {
+             this.window = window;
+         }
+         /**
+          * Resize the physical screen to match the logical screen dimensions.
+          */
+         @Override
+         public void resizeToScreen() {
+             window.setWidth(getWidth() + 2);
+             window.setHeight(getHeight() + 2);
+         }
+         /**
+          * Get the width of a character cell in pixels.
+          *
+          * @return the width in pixels of a character cell
+          */
+         @Override
+         public int getTextWidth() {
+             return window.getScreen().getTextWidth();
+         }
+         /**
+          * Get the height of a character cell in pixels.
+          *
+          * @return the height in pixels of a character cell
+          */
+         @Override
+         public int getTextHeight() {
+             return window.getScreen().getTextHeight();
+         }
+     }
+     // ------------------------------------------------------------------------
+     // Constructors -----------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Public constructor.  Window will be located at (0, 0).
+      *
+      * @param listener the object this backend needs to wake up when new
+      * input comes in
+      * @param application TApplication that manages this window
+      * @param title window title, will be centered along the top border
+      * @param width width of window
+      * @param height height of window
+      */
+     public TWindowBackend(final Object listener,
+         final TApplication application, final String title,
+         final int width, final int height) {
+         super(application, title, width, height);
+         this.listener = listener;
+         eventQueue = new ArrayList<TInputEvent>();
+         sessionInfo = new TSessionInfo(width, height);
+         otherScreen = new OtherScreen(this);
+         otherScreen.setDimensions(width - 2, height - 2);
+         drawLock = otherScreen;
+         setHiddenMouse(true);
+     }
+     /**
+      * Public constructor.  Window will be located at (0, 0).
+      *
+      * @param listener the object this backend needs to wake up when new
+      * input comes in
+      * @param application TApplication that manages this window
+      * @param title window title, will be centered along the top border
+      * @param width width of window
+      * @param height height of window
+      * @param flags bitmask of RESIZABLE, CENTERED, or MODAL
+      */
+     public TWindowBackend(final Object listener,
+         final TApplication application, final String title,
+         final int width, final int height, final int flags) {
+         super(application, title, width, height, flags);
+         this.listener = listener;
+         eventQueue = new ArrayList<TInputEvent>();
+         sessionInfo = new TSessionInfo(width, height);
+         otherScreen = new OtherScreen(this);
+         otherScreen.setDimensions(width - 2, height - 2);
+         drawLock = otherScreen;
+         setHiddenMouse(true);
+     }
+     /**
+      * Public constructor.
+      *
+      * @param listener the object this backend needs to wake up when new
+      * input comes in
+      * @param application TApplication that manages this window
+      * @param title window title, will be centered along the top border
+      * @param x column relative to parent
+      * @param y row relative to parent
+      * @param width width of window
+      * @param height height of window
+      */
+     public TWindowBackend(final Object listener,
+         final TApplication application, final String title,
+         final int x, final int y, final int width, final int height) {
+         super(application, title, x, y, width, height);
+         this.listener = listener;
+         eventQueue = new ArrayList<TInputEvent>();
+         sessionInfo = new TSessionInfo(width, height);
+         otherScreen = new OtherScreen(this);
+         otherScreen.setDimensions(width - 2, height - 2);
+         drawLock = otherScreen;
+         setHiddenMouse(true);
+     }
+     /**
+      * Public constructor.
+      *
+      * @param listener the object this backend needs to wake up when new
+      * input comes in
+      * @param application TApplication that manages this window
+      * @param title window title, will be centered along the top border
+      * @param x column relative to parent
+      * @param y row relative to parent
+      * @param width width of window
+      * @param height height of window
+      * @param flags mask of RESIZABLE, CENTERED, or MODAL
+      */
+     public TWindowBackend(final Object listener,
+         final TApplication application, final String title,
+         final int x, final int y, final int width, final int height,
+         final int flags) {
+         super(application, title, x, y, width, height, flags);
+         this.listener = listener;
+         eventQueue = new ArrayList<TInputEvent>();
+         sessionInfo = new TSessionInfo(width, height);
+         otherScreen = new OtherScreen(this);
+         otherScreen.setDimensions(width - 2, height - 2);
+         drawLock = otherScreen;
+         setHiddenMouse(true);
+     }
+     // ------------------------------------------------------------------------
+     // Event handlers ---------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Handle window/screen resize events.
+      *
+      * @param event resize event
+      */
+     @Override
+     public void onResize(final TResizeEvent event) {
+         if (event.getType() == TResizeEvent.Type.WIDGET) {
+             int newWidth = event.getWidth() - 2;
+             int newHeight = event.getHeight() - 2;
+             if ((newWidth != otherScreen.getWidth())
+                 || (newHeight != otherScreen.getHeight())
+             ) {
+                 // I was resized, notify the screen I am watching to match my
+                 // new size.
+                 synchronized (eventQueue) {
+                     eventQueue.add(new TResizeEvent(TResizeEvent.Type.SCREEN,
+                             newWidth, newHeight));
+                 }
+                 synchronized (listener) {
+                     listener.notifyAll();
+                 }
+             }
+             return;
+         } else {
+             super.onResize(event);
+         }
+     }
+     /**
+      * Returns true if the mouse is currently in the otherScreen window.
+      *
+      * @param mouse mouse event
+      * @return true if mouse is currently in the otherScreen window.
+      */
+     protected boolean mouseOnOtherScreen(final TMouseEvent mouse) {
+         if ((mouse.getY() >= 1)
+             && (mouse.getY() <= otherScreen.getHeight())
+             && (mouse.getX() >= 1)
+             && (mouse.getX() <= otherScreen.getWidth())
+         ) {
+             return true;
+         }
+         return false;
+     }
+     /**
+      * Handle mouse button presses.
+      *
+      * @param mouse mouse button event
+      */
+     @Override
+     public void onMouseDown(final TMouseEvent mouse) {
+         if (mouseOnOtherScreen(mouse)) {
+             TMouseEvent event = mouse.dup();
+             event.setX(mouse.getX() - 1);
+             event.setY(mouse.getY() - 1);
+             event.setAbsoluteX(event.getX());
+             event.setAbsoluteY(event.getY());
+             synchronized (eventQueue) {
+                 eventQueue.add(event);
+             }
+             synchronized (listener) {
+                 listener.notifyAll();
+             }
+         }
+         super.onMouseDown(mouse);
+     }
+     /**
+      * Handle mouse button releases.
+      *
+      * @param mouse mouse button release event
+      */
+     @Override
+     public void onMouseUp(final TMouseEvent mouse) {
+         if (mouseOnOtherScreen(mouse)) {
+             TMouseEvent event = mouse.dup();
+             event.setX(mouse.getX() - 1);
+             event.setY(mouse.getY() - 1);
+             event.setAbsoluteX(event.getX());
+             event.setAbsoluteY(event.getY());
+             synchronized (eventQueue) {
+                 eventQueue.add(event);
+             }
+             synchronized (listener) {
+                 listener.notifyAll();
+             }
+         }
+         super.onMouseUp(mouse);
+     }
+     /**
+      * Handle mouse movements.
+      *
+      * @param mouse mouse motion event
+      */
+     @Override
+     public void onMouseMotion(final TMouseEvent mouse) {
+         if (mouseOnOtherScreen(mouse)) {
+             TMouseEvent event = mouse.dup();
+             event.setX(mouse.getX() - 1);
+             event.setY(mouse.getY() - 1);
+             event.setAbsoluteX(event.getX());
+             event.setAbsoluteY(event.getY());
+             synchronized (eventQueue) {
+                 eventQueue.add(event);
+             }
+             synchronized (listener) {
+                 listener.notifyAll();
+             }
+         }
+         super.onMouseMotion(mouse);
+     }
+     /**
+      * Handle keystrokes.
+      *
+      * @param keypress keystroke event
+      */
+     @Override
+     public void onKeypress(final TKeypressEvent keypress) {
+         TKeypressEvent event = keypress.dup();
+         synchronized (eventQueue) {
+             eventQueue.add(event);
+         }
+         synchronized (listener) {
+             listener.notifyAll();
+         }
+     }
+     // ------------------------------------------------------------------------
+     // TWindow ----------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Draw the foreground colors grid.
+      */
+     @Override
+     public void draw() {
+         // Sync on other screen, so that we do not draw in the middle of
+         // their screen update.
+         synchronized (drawLock) {
+             // Draw the box
+             super.draw();
+             // Draw every cell of the other screen
+             for (int y = 0; y < otherScreen.getHeight(); y++) {
+                 for (int x = 0; x < otherScreen.getWidth(); x++) {
+                     putCharXY(x + 1, y + 1, otherScreen.getCharXY(x, y));
+                 }
+             }
+             // If their cursor is visible, draw that here too.
+             if (otherScreen.isCursorVisible()) {
+                 setCursorX(otherScreen.getCursorX() + 1);
+                 setCursorY(otherScreen.getCursorY() + 1);
+                 setCursorVisible(true);
+             } else {
+                 setCursorVisible(false);
+             }
+         }
+         // Check if the other application has died.  If so, unset hidden
+         // mouse.
+         if (otherApplication != null) {
+             if (otherApplication.isRunning() == false) {
+                 setHiddenMouse(false);
+             }
+         }
+     }
+     /**
+      * Subclasses should override this method to cleanup resources.  This is
+      * called by application.closeWindow().
+      */
+     @Override
+     public void onClose() {
+         synchronized (eventQueue) {
+             eventQueue.add(new TCommandEvent(cmBackendDisconnect));
+         }
+     }
+     // ------------------------------------------------------------------------
+     // Backend ----------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Getter for sessionInfo.
+      *
+      * @return the SessionInfo
+      */
+     public final SessionInfo getSessionInfo() {
+         return sessionInfo;
+     }
+     /**
+      * Subclasses must provide an implementation that syncs the logical
+      * screen to the physical device.
+      */
+     public void flushScreen() {
+         getApplication().doRepaint();
+     }
+     /**
+      * Check if there are events in the queue.
+      *
+      * @return if true, getEvents() has something to return to the application
+      */
+     public boolean hasEvents() {
+         synchronized (eventQueue) {
+             return (eventQueue.size() > 0);
+         }
+     }
+     /**
+      * Subclasses must provide an implementation to get keyboard, mouse, and
+      * screen resize events.
+      *
+      * @param queue list to append new events to
+      */
+     public void getEvents(List<TInputEvent> queue) {
+         synchronized (eventQueue) {
+             if (eventQueue.size() > 0) {
+                 synchronized (queue) {
+                     queue.addAll(eventQueue);
+                 }
+                 eventQueue.clear();
+             }
+         }
+     }
+     /**
+      * Subclasses must provide an implementation that closes sockets,
+      * restores console, etc.
+      */
+     public void shutdown() {
+         // NOP
+     }
+     /**
+      * 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;
+     }
+     /**
+      * Reload backend options from System properties.
+      */
+     public void reloadOptions() {
+         // NOP
+     }
+     // ------------------------------------------------------------------------
+     // TWindowBackend ---------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Set the object to sync to in draw().
+      *
+      * @param drawLock the object to synchronize on
+      */
+     public void setDrawLock(final Object drawLock) {
+         this.drawLock = drawLock;
+     }
+     /**
+      * Getter for the other application's screen.
+      *
+      * @return the Screen
+      */
+     public Screen getOtherScreen() {
+         return otherScreen;
+     }
+     /**
+      * Set the other screen's application.
+      *
+      * @param application the application driving the other screen
+      */
+     public void setOtherApplication(final TApplication application) {
+         this.otherApplication = application;
+     }
+ }
index 0000000000000000000000000000000000000000,32033e0b1943c12999621566f96e29f7465cff22..32033e0b1943c12999621566f96e29f7465cff22
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,74 +1,74 @@@
+ /*
+  * Jexer - Java Text User Interface
+  *
+  * The MIT License (MIT)
+  *
+  * 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"),
+  * to deal in the Software without restriction, including without limitation
+  * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+  * and/or sell copies of the Software, and to permit persons to whom the
+  * Software is furnished to do so, subject to the following conditions:
+  *
+  * The above copyright notice and this permission notice shall be included in
+  * all copies or substantial portions of the Software.
+  *
+  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+  * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+  * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+  * DEALINGS IN THE SOFTWARE.
+  *
+  * @author Kevin Lamonte [kevin.lamonte@gmail.com]
+  * @version 1
+  */
+ package jexer.backend;
+ import java.util.List;
+ import jexer.event.TInputEvent;
+ /**
+  * TerminalReader provides keyboard and mouse events.
+  */
+ public interface TerminalReader {
+     /**
+      * Check if there are events in the queue.
+      *
+      * @return if true, getEvents() has something to return to the backend
+      */
+     public boolean hasEvents();
+     /**
+      * Classes must provide an implementation to get keyboard, mouse, and
+      * screen resize events.
+      *
+      * @param queue list to append new events to
+      */
+     public void getEvents(List<TInputEvent> queue);
+     /**
+      * Classes must provide an implementation that closes sockets, restores
+      * console, etc.
+      */
+     public void closeTerminal();
+     /**
+      * 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);
+     /**
+      * Reload options from System properties.
+      */
+     public void reloadOptions();
+ }
index 0000000000000000000000000000000000000000,46d8ba13bf0d05b53b95cc61093002a426d9a555..46d8ba13bf0d05b53b95cc61093002a426d9a555
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,33 +1,33 @@@
+ /*
+  * Jexer - Java Text User Interface
+  *
+  * The MIT License (MIT)
+  *
+  * 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"),
+  * to deal in the Software without restriction, including without limitation
+  * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+  * and/or sell copies of the Software, and to permit persons to whom the
+  * Software is furnished to do so, subject to the following conditions:
+  *
+  * The above copyright notice and this permission notice shall be included in
+  * all copies or substantial portions of the Software.
+  *
+  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+  * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+  * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+  * DEALINGS IN THE SOFTWARE.
+  *
+  * @author Kevin Lamonte [kevin.lamonte@gmail.com]
+  * @version 1
+  */
+ /**
+  * The interface between TApplication and user-facing I/O.
+  */
+ package jexer.backend;
diff --combined src/jexer/bits/Cell.java
index 0000000000000000000000000000000000000000,a8efa2b3c56465dc7ee93dc76fefa399bed85603..a8efa2b3c56465dc7ee93dc76fefa399bed85603
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,485 +1,485 @@@
+ /*
+  * Jexer - Java Text User Interface
+  *
+  * The MIT License (MIT)
+  *
+  * 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"),
+  * to deal in the Software without restriction, including without limitation
+  * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+  * and/or sell copies of the Software, and to permit persons to whom the
+  * Software is furnished to do so, subject to the following conditions:
+  *
+  * The above copyright notice and this permission notice shall be included in
+  * all copies or substantial portions of the Software.
+  *
+  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+  * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+  * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+  * DEALINGS IN THE SOFTWARE.
+  *
+  * @author Kevin Lamonte [kevin.lamonte@gmail.com]
+  * @version 1
+  */
+ package jexer.bits;
+ import java.awt.Color;
+ import java.awt.image.BufferedImage;
+ /**
+  * This class represents a single text cell or bit of image on the screen.
+  */
+ public final class Cell extends CellAttributes {
+     // ------------------------------------------------------------------------
+     // Constants --------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * How this cell needs to be displayed if it is part of a larger glyph.
+      */
+     public enum Width {
+         /**
+          * This cell is an entire glyph on its own.
+          */
+         SINGLE,
+         /**
+          * This cell is the left half of a wide glyph.
+          */
+         LEFT,
+         /**
+          * This cell is the right half of a wide glyph.
+          */
+         RIGHT,
+     }
+     /**
+      * The special "this cell is unset" (null) value.  This is the Unicode
+      * "not a character" value.
+      */
+     private static final char UNSET_VALUE = (char) 65535;
+     // ------------------------------------------------------------------------
+     // Variables --------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * The character at this cell.
+      */
+     private int ch = ' ';
+     /**
+      * The display width of this cell.
+      */
+     private Width width = Width.SINGLE;
+     /**
+      * The image at this cell.
+      */
+     private BufferedImage image = null;
+     /**
+      * The image at this cell, inverted.
+      */
+     private BufferedImage invertedImage = null;
+     /**
+      * The background color used for the area the image portion might not
+      * cover.
+      */
+     private Color background = Color.BLACK;
+     /**
+      * hashCode() needs to call image.hashCode(), which can get quite
+      * expensive.
+      */
+     private int imageHashCode = 0;
+     /**
+      * hashCode() needs to call background.hashCode(), which can get quite
+      * expensive.
+      */
+     private int backgroundHashCode = 0;
+     // ------------------------------------------------------------------------
+     // Constructors -----------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Public constructor sets default values of the cell to blank.
+      *
+      * @see #isBlank()
+      * @see #reset()
+      */
+     public Cell() {
+         // NOP
+     }
+     /**
+      * Public constructor sets the character.  Attributes are the same as
+      * default.
+      *
+      * @param ch character to set to
+      * @see #reset()
+      */
+     public Cell(final int ch) {
+         this.ch = ch;
+     }
+     /**
+      * Public constructor sets the attributes.
+      *
+      * @param attr attributes to use
+      */
+     public Cell(final CellAttributes attr) {
+         super(attr);
+     }
+     /**
+      * Public constructor sets the character and attributes.
+      *
+      * @param ch character to set to
+      * @param attr attributes to use
+      */
+     public Cell(final int ch, final CellAttributes attr) {
+         super(attr);
+         this.ch = ch;
+     }
+     /**
+      * Public constructor creates a duplicate.
+      *
+      * @param cell the instance to copy
+      */
+     public Cell(final Cell cell) {
+         setTo(cell);
+     }
+     // ------------------------------------------------------------------------
+     // Cell -------------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Set the image data for this cell.
+      *
+      * @param image the image for this cell
+      */
+     public void setImage(final BufferedImage image) {
+         this.image = image;
+         imageHashCode = image.hashCode();
+         width = Width.SINGLE;
+     }
+     /**
+      * Get the image data for this cell.
+      *
+      * @return the image for this cell
+      */
+     public BufferedImage getImage() {
+         if (invertedImage != null) {
+             return invertedImage;
+         }
+         return image;
+     }
+     /**
+      * Get the bitmap image background color for this cell.
+      *
+      * @return the bitmap image background color
+      */
+     public Color getBackground() {
+         return background;
+     }
+     /**
+      * If true, this cell has image data.
+      *
+      * @return true if this cell is an image rather than a character with
+      * attributes
+      */
+     public boolean isImage() {
+         if (image != null) {
+             return true;
+         }
+         return false;
+     }
+     /**
+      * Restore the image in this cell to its normal version, if it has one.
+      */
+     public void restoreImage() {
+         invertedImage = null;
+     }
+     /**
+      * If true, this cell has image data, and that data is inverted.
+      *
+      * @return true if this cell is an image rather than a character with
+      * attributes, and the data is inverted
+      */
+     public boolean isInvertedImage() {
+         if ((image != null) && (invertedImage != null)) {
+             return true;
+         }
+         return false;
+     }
+     /**
+      * Invert the image in this cell, if it has one.
+      */
+     public void invertImage() {
+         if (image == null) {
+             return;
+         }
+         if (invertedImage == null) {
+             invertedImage = new BufferedImage(image.getWidth(),
+                 image.getHeight(), BufferedImage.TYPE_INT_ARGB);
+             int [] rgbArray = image.getRGB(0, 0,
+                 image.getWidth(), image.getHeight(), null, 0, image.getWidth());
+             for (int i = 0; i < rgbArray.length; i++) {
+                 // Set the colors to fully inverted.
+                 if (rgbArray[i] != 0x00FFFFFF) {
+                     rgbArray[i] ^= 0x00FFFFFF;
+                 }
+                 // Also set alpha to non-transparent.
+                 rgbArray[i] |= 0xFF000000;
+             }
+             invertedImage.setRGB(0, 0, image.getWidth(), image.getHeight(),
+                 rgbArray, 0, image.getWidth());
+         }
+     }
+     /**
+      * Getter for cell character.
+      *
+      * @return cell character
+      */
+     public int getChar() {
+         return ch;
+     }
+     /**
+      * Setter for cell character.
+      *
+      * @param ch new cell character
+      */
+     public void setChar(final int ch) {
+         this.ch = ch;
+     }
+     /**
+      * Getter for cell width.
+      *
+      * @return Width.SINGLE, Width.LEFT, or Width.RIGHT
+      */
+     public Width getWidth() {
+         return width;
+     }
+     /**
+      * Setter for cell width.
+      *
+      * @param width new cell width, one of Width.SINGLE, Width.LEFT, or
+      * Width.RIGHT
+      */
+     public void setWidth(final Width width) {
+         this.width = width;
+     }
+     /**
+      * Reset this cell to a blank.
+      */
+     @Override
+     public void reset() {
+         super.reset();
+         ch = ' ';
+         width = Width.SINGLE;
+         image = null;
+         imageHashCode = 0;
+         invertedImage = null;
+         background = Color.BLACK;
+         backgroundHashCode = 0;
+     }
+     /**
+      * UNset this cell.  It will not be equal to any other cell until it has
+      * been assigned attributes and a character.
+      */
+     public void unset() {
+         super.reset();
+         ch = UNSET_VALUE;
+         width = Width.SINGLE;
+         image = null;
+         imageHashCode = 0;
+         invertedImage = null;
+         background = Color.BLACK;
+         backgroundHashCode = 0;
+     }
+     /**
+      * Check to see if this cell has default attributes: white foreground,
+      * black background, no bold/blink/reverse/underline/protect, and a
+      * character value of ' ' (space).
+      *
+      * @return true if this cell has default attributes.
+      */
+     public boolean isBlank() {
+         if ((ch == UNSET_VALUE) || (image != null)) {
+             return false;
+         }
+         if ((getForeColor().equals(Color.WHITE))
+             && (getBackColor().equals(Color.BLACK))
+             && !isBold()
+             && !isBlink()
+             && !isReverse()
+             && !isUnderline()
+             && !isProtect()
+             && !isRGB()
+             && !isImage()
+             && (width == Width.SINGLE)
+             && (ch == ' ')
+         ) {
+             return true;
+         }
+         return false;
+     }
+     /**
+      * Comparison check.  All fields must match to return true.
+      *
+      * @param rhs another Cell instance
+      * @return true if all fields are equal
+      */
+     @Override
+     public boolean equals(final Object rhs) {
+         if (!(rhs instanceof Cell)) {
+             return false;
+         }
+         Cell that = (Cell) rhs;
+         // Unsetted cells can never be equal.
+         if ((ch == UNSET_VALUE) || (that.ch == UNSET_VALUE)) {
+             return false;
+         }
+         // If this or rhs has an image and the other doesn't, these are not
+         // equal.
+         if ((image != null) && (that.image == null)) {
+             return false;
+         }
+         if ((image == null) && (that.image != null)) {
+             return false;
+         }
+         // If this and rhs have images, both must match.
+         if ((image != null) && (that.image != null)) {
+             if ((invertedImage == null) && (that.invertedImage != null)) {
+                 return false;
+             }
+             if ((invertedImage != null) && (that.invertedImage == null)) {
+                 return false;
+             }
+             // Either both objects have their image inverted, or neither do.
+             // Now if the images are identical the cells are the same
+             // visually.
+             if (image.equals(that.image)
+                 && (background.equals(that.background))
+             ) {
+                 return true;
+             } else {
+                 return false;
+             }
+         }
+         // Normal case: character and attributes must match.
+         if ((ch == that.ch) && (width == that.width)) {
+             return super.equals(rhs);
+         }
+         return false;
+     }
+     /**
+      * Hashcode uses all fields in equals().
+      *
+      * @return the hash
+      */
+     @Override
+     public int hashCode() {
+         int A = 13;
+         int B = 23;
+         int hash = A;
+         hash = (B * hash) + super.hashCode();
+         hash = (B * hash) + (int)ch;
+         hash = (B * hash) + width.hashCode();
+         if (image != null) {
+             /*
+             hash = (B * hash) + image.hashCode();
+             hash = (B * hash) + background.hashCode();
+              */
+             hash = (B * hash) + imageHashCode;
+             hash = (B * hash) + backgroundHashCode;
+         }
+         if (invertedImage != null) {
+             hash = (B * hash) + invertedImage.hashCode();
+         }
+         return hash;
+     }
+     /**
+      * Set my field values to that's field.
+      *
+      * @param rhs an instance of either Cell or CellAttributes
+      */
+     @Override
+     public void setTo(final Object rhs) {
+         // Let this throw a ClassCastException
+         CellAttributes thatAttr = (CellAttributes) rhs;
+         this.image = null;
+         this.imageHashCode = 0;
+         this.backgroundHashCode = 0;
+         this.width = Width.SINGLE;
+         super.setTo(thatAttr);
+         if (rhs instanceof Cell) {
+             Cell that = (Cell) rhs;
+             this.ch = that.ch;
+             this.width = that.width;
+             this.image = that.image;
+             this.invertedImage = that.invertedImage;
+             this.background = that.background;
+             this.imageHashCode = that.imageHashCode;
+             this.backgroundHashCode = that.backgroundHashCode;
+         }
+     }
+     /**
+      * Set my field attr values to that's field.
+      *
+      * @param that a CellAttributes instance
+      */
+     public void setAttr(final CellAttributes that) {
+         image = null;
+         super.setTo(that);
+     }
+     /**
+      * Make human-readable description of this Cell.
+      *
+      * @return displayable String
+      */
+     @Override
+     public String toString() {
+         return String.format("fore: %s back: %s bold: %s blink: %s ch %c",
+             getForeColor(), getBackColor(), isBold(), isBlink(), ch);
+     }
+ }
index 0000000000000000000000000000000000000000,99366fda690740b738563493fef900a506436d7d..99366fda690740b738563493fef900a506436d7d
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,395 +1,395 @@@
+ /*
+  * Jexer - Java Text User Interface
+  *
+  * The MIT License (MIT)
+  *
+  * 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"),
+  * to deal in the Software without restriction, including without limitation
+  * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+  * and/or sell copies of the Software, and to permit persons to whom the
+  * Software is furnished to do so, subject to the following conditions:
+  *
+  * The above copyright notice and this permission notice shall be included in
+  * all copies or substantial portions of the Software.
+  *
+  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+  * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+  * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+  * DEALINGS IN THE SOFTWARE.
+  *
+  * @author Kevin Lamonte [kevin.lamonte@gmail.com]
+  * @version 1
+  */
+ package jexer.bits;
+ /**
+  * The attributes used by a Cell: color, bold, blink, etc.
+  */
+ public class CellAttributes {
+     // ------------------------------------------------------------------------
+     // Constants --------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Bold attribute.
+      */
+     private static final int BOLD       = 0x01;
+     /**
+      * Blink attribute.
+      */
+     private static final int BLINK      = 0x02;
+     /**
+      * Reverse attribute.
+      */
+     private static final int REVERSE    = 0x04;
+     /**
+      * Underline attribute.
+      */
+     private static final int UNDERLINE  = 0x08;
+     /**
+      * Protected attribute.
+      */
+     private static final int PROTECT    = 0x10;
+     // ------------------------------------------------------------------------
+     // Variables --------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Boolean flags.
+      */
+     private int flags = 0;
+     /**
+      * Foreground color.  Color.WHITE, Color.RED, etc.
+      */
+     private Color foreColor = Color.WHITE;
+     /**
+      * Background color.  Color.WHITE, Color.RED, etc.
+      */
+     private Color backColor = Color.BLACK;
+     /**
+      * Foreground color as 24-bit RGB value.  Negative value means not set.
+      */
+     private int foreColorRGB = -1;
+     /**
+      * Background color as 24-bit RGB value.  Negative value means not set.
+      */
+     private int backColorRGB = -1;
+     // ------------------------------------------------------------------------
+     // Constructors -----------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Public constructor sets default values of the cell to white-on-black,
+      * no bold/blink/reverse/underline/protect.
+      *
+      * @see #reset()
+      */
+     public CellAttributes() {
+         // NOP
+     }
+     /**
+      * Public constructor makes a copy from another instance.
+      *
+      * @param that another CellAttributes instance
+      * @see #reset()
+      */
+     public CellAttributes(final CellAttributes that) {
+         setTo(that);
+     }
+     // ------------------------------------------------------------------------
+     // CellAttributes ---------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Getter for bold.
+      *
+      * @return bold value
+      */
+     public final boolean isBold() {
+         return ((flags & BOLD) == 0 ? false : true);
+     }
+     /**
+      * Setter for bold.
+      *
+      * @param bold new bold value
+      */
+     public final void setBold(final boolean bold) {
+         if (bold) {
+             flags |= BOLD;
+         } else {
+             flags &= ~BOLD;
+         }
+     }
+     /**
+      * Getter for blink.
+      *
+      * @return blink value
+      */
+     public final boolean isBlink() {
+         return ((flags & BLINK) == 0 ? false : true);
+     }
+     /**
+      * Setter for blink.
+      *
+      * @param blink new blink value
+      */
+     public final void setBlink(final boolean blink) {
+         if (blink) {
+             flags |= BLINK;
+         } else {
+             flags &= ~BLINK;
+         }
+     }
+     /**
+      * Getter for reverse.
+      *
+      * @return reverse value
+      */
+     public final boolean isReverse() {
+         return ((flags & REVERSE) == 0 ? false : true);
+     }
+     /**
+      * Setter for reverse.
+      *
+      * @param reverse new reverse value
+      */
+     public final void setReverse(final boolean reverse) {
+         if (reverse) {
+             flags |= REVERSE;
+         } else {
+             flags &= ~REVERSE;
+         }
+     }
+     /**
+      * Getter for underline.
+      *
+      * @return underline value
+      */
+     public final boolean isUnderline() {
+         return ((flags & UNDERLINE) == 0 ? false : true);
+     }
+     /**
+      * Setter for underline.
+      *
+      * @param underline new underline value
+      */
+     public final void setUnderline(final boolean underline) {
+         if (underline) {
+             flags |= UNDERLINE;
+         } else {
+             flags &= ~UNDERLINE;
+         }
+     }
+     /**
+      * Getter for protect.
+      *
+      * @return protect value
+      */
+     public final boolean isProtect() {
+         return ((flags & PROTECT) == 0 ? false : true);
+     }
+     /**
+      * Setter for protect.
+      *
+      * @param protect new protect value
+      */
+     public final void setProtect(final boolean protect) {
+         if (protect) {
+             flags |= PROTECT;
+         } else {
+             flags &= ~PROTECT;
+         }
+     }
+     /**
+      * Getter for foreColor.
+      *
+      * @return foreColor value
+      */
+     public final Color getForeColor() {
+         return foreColor;
+     }
+     /**
+      * Setter for foreColor.
+      *
+      * @param foreColor new foreColor value
+      */
+     public final void setForeColor(final Color foreColor) {
+         this.foreColor = foreColor;
+     }
+     /**
+      * Getter for backColor.
+      *
+      * @return backColor value
+      */
+     public final Color getBackColor() {
+         return backColor;
+     }
+     /**
+      * Setter for backColor.
+      *
+      * @param backColor new backColor value
+      */
+     public final void setBackColor(final Color backColor) {
+         this.backColor = backColor;
+     }
+     /**
+      * Getter for foreColor RGB.
+      *
+      * @return foreColor value.  Negative means unset.
+      */
+     public final int getForeColorRGB() {
+         return foreColorRGB;
+     }
+     /**
+      * Setter for foreColor RGB.
+      *
+      * @param foreColorRGB new foreColor RGB value
+      */
+     public final void setForeColorRGB(final int foreColorRGB) {
+         this.foreColorRGB = foreColorRGB;
+     }
+     /**
+      * Getter for backColor RGB.
+      *
+      * @return backColor value.  Negative means unset.
+      */
+     public final int getBackColorRGB() {
+         return backColorRGB;
+     }
+     /**
+      * Setter for backColor RGB.
+      *
+      * @param backColorRGB new backColor RGB value
+      */
+     public final void setBackColorRGB(final int backColorRGB) {
+         this.backColorRGB = backColorRGB;
+     }
+     /**
+      * See if this cell uses RGB or ANSI colors.
+      *
+      * @return true if this cell has a RGB color
+      */
+     public final boolean isRGB() {
+         return (foreColorRGB >= 0) || (backColorRGB >= 0);
+     }
+     /**
+      * Set to default: white foreground on black background, no
+      * bold/underline/blink/rever/protect.
+      */
+     public void reset() {
+         flags           = 0;
+         foreColor       = Color.WHITE;
+         backColor       = Color.BLACK;
+         foreColorRGB    = -1;
+         backColorRGB    = -1;
+     }
+     /**
+      * Comparison check.  All fields must match to return true.
+      *
+      * @param rhs another CellAttributes instance
+      * @return true if all fields are equal
+      */
+     @Override
+     public boolean equals(final Object rhs) {
+         if (!(rhs instanceof CellAttributes)) {
+             return false;
+         }
+         CellAttributes that = (CellAttributes) rhs;
+         return ((flags == that.flags)
+             && (foreColor == that.foreColor)
+             && (backColor == that.backColor)
+             && (foreColorRGB == that.foreColorRGB)
+             && (backColorRGB == that.backColorRGB));
+     }
+     /**
+      * Hashcode uses all fields in equals().
+      *
+      * @return the hash
+      */
+     @Override
+     public int hashCode() {
+         int A = 13;
+         int B = 23;
+         int hash = A;
+         hash = (B * hash) + flags;
+         hash = (B * hash) + foreColor.hashCode();
+         hash = (B * hash) + backColor.hashCode();
+         hash = (B * hash) + foreColorRGB;
+         hash = (B * hash) + backColorRGB;
+         return hash;
+     }
+     /**
+      * Set my field values to that's field.
+      *
+      * @param rhs another CellAttributes instance
+      */
+     public void setTo(final Object rhs) {
+         CellAttributes that = (CellAttributes) rhs;
+         this.flags              = that.flags;
+         this.foreColor          = that.foreColor;
+         this.backColor          = that.backColor;
+         this.foreColorRGB       = that.foreColorRGB;
+         this.backColorRGB       = that.backColorRGB;
+     }
+     /**
+      * Make human-readable description of this CellAttributes.
+      *
+      * @return displayable String
+      */
+     @Override
+     public String toString() {
+         if ((foreColorRGB >= 0) || (backColorRGB >= 0)) {
+             return String.format("RGB: #%06x on #%06x",
+                 (foreColorRGB & 0xFFFFFF),
+                 (backColorRGB & 0xFFFFFF));
+         }
+         return String.format("%s%s%s on %s", (isBold() ? "bold " : ""),
+             (isBlink() ? "blink " : ""), foreColor, backColor);
+     }
+ }
index 0000000000000000000000000000000000000000,4defed5cfad7b35af25e901f2a165612b037fb71..4defed5cfad7b35af25e901f2a165612b037fb71
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,272 +1,272 @@@
+ /*
+  * Jexer - Java Text User Interface
+  *
+  * The MIT License (MIT)
+  *
+  * 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"),
+  * to deal in the Software without restriction, including without limitation
+  * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+  * and/or sell copies of the Software, and to permit persons to whom the
+  * Software is furnished to do so, subject to the following conditions:
+  *
+  * The above copyright notice and this permission notice shall be included in
+  * all copies or substantial portions of the Software.
+  *
+  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+  * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+  * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+  * DEALINGS IN THE SOFTWARE.
+  *
+  * @author Kevin Lamonte [kevin.lamonte@gmail.com]
+  * @version 1
+  */
+ package jexer.bits;
+ /**
+  * A text cell color.
+  */
+ public final class Color {
+     // ------------------------------------------------------------------------
+     // Constants --------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * SGR black value = 0.
+      */
+     private static final int SGRBLACK   = 0;
+     /**
+      * SGR red value = 1.
+      */
+     private static final int SGRRED     = 1;
+     /**
+      * SGR green value = 2.
+      */
+     private static final int SGRGREEN   = 2;
+     /**
+      * SGR yellow value = 3.
+      */
+     private static final int SGRYELLOW  = 3;
+     /**
+      * SGR blue value = 4.
+      */
+     private static final int SGRBLUE    = 4;
+     /**
+      * SGR magenta value = 5.
+      */
+     private static final int SGRMAGENTA = 5;
+     /**
+      * SGR cyan value = 6.
+      */
+     private static final int SGRCYAN    = 6;
+     /**
+      * SGR white value = 7.
+      */
+     private static final int SGRWHITE   = 7;
+     /**
+      * Black.  Bold + black = dark grey
+      */
+     public static final Color BLACK = new Color(SGRBLACK);
+     /**
+      * Red.
+      */
+     public static final Color RED = new Color(SGRRED);
+     /**
+      * Green.
+      */
+     public static final Color GREEN  = new Color(SGRGREEN);
+     /**
+      * Yellow.  Sometimes not-bold yellow is brown.
+      */
+     public static final Color YELLOW = new Color(SGRYELLOW);
+     /**
+      * Blue.
+      */
+     public static final Color BLUE = new Color(SGRBLUE);
+     /**
+      * Magenta (purple).
+      */
+     public static final Color MAGENTA = new Color(SGRMAGENTA);
+     /**
+      * Cyan (blue-green).
+      */
+     public static final Color CYAN = new Color(SGRCYAN);
+     /**
+      * White.
+      */
+     public static final Color WHITE = new Color(SGRWHITE);
+     // ------------------------------------------------------------------------
+     // Variables --------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * The color value.  Default is SGRWHITE.
+      */
+     private int value = SGRWHITE;
+     // ------------------------------------------------------------------------
+     // Constructors -----------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Private constructor used to make the static Color instances.
+      *
+      * @param value the integer Color value
+      */
+     private Color(final int value) {
+         this.value = value;
+     }
+     // ------------------------------------------------------------------------
+     // Color ------------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Get color value.  Note that these deliberately match the color values
+      * of the ECMA-48 / ANSI X3.64 / VT100-ish SGR function ("ANSI colors").
+      *
+      * @return the value
+      */
+     public int getValue() {
+         return value;
+     }
+     /**
+      * Public constructor returns one of the static Color instances.
+      *
+      * @param colorName "red", "blue", etc.
+      * @return Color.RED, Color.BLUE, etc.
+      */
+     static Color getColor(final String colorName) {
+         String str = colorName.toLowerCase();
+         if (str.equals("black")) {
+             return Color.BLACK;
+         } else if (str.equals("white")) {
+             return Color.WHITE;
+         } else if (str.equals("red")) {
+             return Color.RED;
+         } else if (str.equals("cyan")) {
+             return Color.CYAN;
+         } else if (str.equals("green")) {
+             return Color.GREEN;
+         } else if (str.equals("magenta")) {
+             return Color.MAGENTA;
+         } else if (str.equals("blue")) {
+             return Color.BLUE;
+         } else if (str.equals("yellow")) {
+             return Color.YELLOW;
+         } else if (str.equals("brown")) {
+             return Color.YELLOW;
+         } else {
+             // Let unknown strings become white
+             return Color.WHITE;
+         }
+     }
+     /**
+      * Invert a color in the same way as (CGA/VGA color XOR 0x7).
+      *
+      * @return the inverted color
+      */
+     public Color invert() {
+         switch (value) {
+         case SGRBLACK:
+             return Color.WHITE;
+         case SGRWHITE:
+             return Color.BLACK;
+         case SGRRED:
+             return Color.CYAN;
+         case SGRCYAN:
+             return Color.RED;
+         case SGRGREEN:
+             return Color.MAGENTA;
+         case SGRMAGENTA:
+             return Color.GREEN;
+         case SGRBLUE:
+             return Color.YELLOW;
+         case SGRYELLOW:
+             return Color.BLUE;
+         default:
+             throw new IllegalArgumentException("Invalid Color value: " + value);
+         }
+     }
+     /**
+      * Comparison check.  All fields must match to return true.
+      *
+      * @param rhs another Color instance
+      * @return true if all fields are equal
+      */
+     @Override
+     public boolean equals(final Object rhs) {
+         if (!(rhs instanceof Color)) {
+             return false;
+         }
+         Color that = (Color) rhs;
+         return (value == that.value);
+     }
+     /**
+      * Hashcode uses all fields in equals().
+      *
+      * @return the hash
+      */
+     @Override
+     public int hashCode() {
+         return value;
+     }
+     /**
+      * Make human-readable description of this Color.
+      *
+      * @return displayable String "red", "blue", etc.
+      */
+     @Override
+     public String toString() {
+         switch (value) {
+         case SGRBLACK:
+             return "black";
+         case SGRWHITE:
+             return "white";
+         case SGRRED:
+             return "red";
+         case SGRCYAN:
+             return "cyan";
+         case SGRGREEN:
+             return "green";
+         case SGRMAGENTA:
+             return "magenta";
+         case SGRBLUE:
+             return "blue";
+         case SGRYELLOW:
+             return "yellow";
+         default:
+             throw new IllegalArgumentException("Invalid Color value: " + value);
+         }
+     }
+ }
index 0000000000000000000000000000000000000000,ffba4d472cc67c36ebda4114e503ba1db545c719..ffba4d472cc67c36ebda4114e503ba1db545c719
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,688 +1,688 @@@
+ /*
+  * Jexer - Java Text User Interface
+  *
+  * The MIT License (MIT)
+  *
+  * 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"),
+  * to deal in the Software without restriction, including without limitation
+  * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+  * and/or sell copies of the Software, and to permit persons to whom the
+  * Software is furnished to do so, subject to the following conditions:
+  *
+  * The above copyright notice and this permission notice shall be included in
+  * all copies or substantial portions of the Software.
+  *
+  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+  * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+  * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+  * DEALINGS IN THE SOFTWARE.
+  *
+  * @author Kevin Lamonte [kevin.lamonte@gmail.com]
+  * @version 1
+  */
+ package jexer.bits;
+ import java.io.BufferedReader;
+ import java.io.FileReader;
+ import java.io.FileWriter;
+ import java.io.IOException;
+ import java.io.Reader;
+ import java.util.ArrayList;
+ import java.util.List;
+ import java.util.Set;
+ import java.util.SortedMap;
+ import java.util.StringTokenizer;
+ import java.util.TreeMap;
+ /**
+  * ColorTheme is a collection of colors keyed by string.  A default theme is
+  * also provided that matches the blue-and-white theme used by Turbo Vision.
+  */
+ public class ColorTheme {
+     // ------------------------------------------------------------------------
+     // Variables --------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * The current theme colors.
+      */
+     private SortedMap<String, CellAttributes> colors;
+     // ------------------------------------------------------------------------
+     // Constructors -----------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Public constructor sets the theme to the default.
+      */
+     public ColorTheme() {
+         colors = new TreeMap<String, CellAttributes>();
+         setDefaultTheme();
+     }
+     // ------------------------------------------------------------------------
+     // ColorTheme -------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Retrieve the CellAttributes for a named theme color.
+      *
+      * @param name theme color name, e.g. "twindow.border"
+      * @return color associated with name, e.g. bold yellow on blue
+      */
+     public CellAttributes getColor(final String name) {
+         CellAttributes attr = colors.get(name);
+         return attr;
+     }
+     /**
+      * Retrieve all the names in the theme.
+      *
+      * @return a list of names
+      */
+     public List<String> getColorNames() {
+         Set<String> keys = colors.keySet();
+         List<String> names = new ArrayList<String>(keys.size());
+         names.addAll(keys);
+         return names;
+     }
+     /**
+      * Set the color for a named theme color.
+      *
+      * @param name theme color name, e.g. "twindow.border"
+      * @param color the new color to associate with name, e.g. bold yellow on
+      * blue
+      */
+     public void setColor(final String name, final CellAttributes color) {
+         colors.put(name, color);
+     }
+     /**
+      * Save the color theme mappings to an ASCII file.
+      *
+      * @param filename file to write to
+      * @throws IOException if the I/O fails
+      */
+     public void save(final String filename) throws IOException {
+         FileWriter file = new FileWriter(filename);
+         for (String key: colors.keySet()) {
+             CellAttributes color = getColor(key);
+             file.write(String.format("%s = %s\n", key, color));
+         }
+         file.close();
+     }
+     /**
+      * Read color theme mappings from an ASCII file.
+      *
+      * @param filename file to read from
+      * @throws IOException if the I/O fails
+      */
+     public void load(final String filename) throws IOException {
+         load(new FileReader(filename));
+     }
+     /**
+      * Set a color based on a text string.  Color text string is of the form:
+      * <code>[ bold ] [ blink ] { foreground on background }</code>
+      *
+      * @param key the color key string
+      * @param text the text string
+      */
+     public void setColorFromString(final String key, final String text) {
+         boolean bold = false;
+         boolean blink = false;
+         String foreColor;
+         String backColor;
+         String token;
+         StringTokenizer tokenizer = new StringTokenizer(text);
+         token = tokenizer.nextToken();
+         if (token.toLowerCase().equals("rgb:")) {
+             // Foreground
+             int foreColorRGB = -1;
+             try {
+                 foreColorRGB = Integer.parseInt(tokenizer.nextToken(), 16);
+             } catch (NumberFormatException e) {
+                 // Default to white on black
+                 foreColorRGB = 0xFFFFFF;
+             }
+             // "on"
+             if (!tokenizer.nextToken().toLowerCase().equals("on")) {
+                 // Invalid line.
+                 return;
+             }
+             // Background
+             int backColorRGB = -1;
+             try {
+                 backColorRGB = Integer.parseInt(tokenizer.nextToken(), 16);
+             } catch (NumberFormatException e) {
+                 backColorRGB = 0;
+             }
+             CellAttributes color = new CellAttributes();
+             color.setForeColorRGB(foreColorRGB);
+             color.setBackColorRGB(backColorRGB);
+             colors.put(key, color);
+             return;
+         }
+         while (token.equals("bold") || token.equals("blink")) {
+             if (token.equals("bold")) {
+                 bold = true;
+                 token = tokenizer.nextToken();
+             }
+             if (token.equals("blink")) {
+                 blink = true;
+                 token = tokenizer.nextToken();
+             }
+         }
+         // What's left is "blah on blah"
+         foreColor = token.toLowerCase();
+         if (!tokenizer.nextToken().toLowerCase().equals("on")) {
+             // Invalid line.
+             return;
+         }
+         backColor = tokenizer.nextToken().toLowerCase();
+         CellAttributes color = new CellAttributes();
+         if (bold) {
+             color.setBold(true);
+         }
+         if (blink) {
+             color.setBlink(true);
+         }
+         color.setForeColor(Color.getColor(foreColor));
+         color.setBackColor(Color.getColor(backColor));
+         colors.put(key, color);
+     }
+     /**
+      * Read color theme mappings from a Reader.  The reader is closed at the
+      * end.
+      *
+      * @param reader the reader to read from
+      * @throws IOException if the I/O fails
+      */
+     public void load(final Reader reader) throws IOException {
+         BufferedReader bufferedReader = new BufferedReader(reader);
+         String line = bufferedReader.readLine();
+         for (; line != null; line = bufferedReader.readLine()) {
+             // Look for lines that resemble:
+             //     "key = blah on blah"
+             //     "key = bold blah on blah"
+             //     "key = blink bold blah on blah"
+             //     "key = bold blink blah on blah"
+             //     "key = blink blah on blah"
+             if (line.indexOf('=') == -1) {
+                 // Invalid line.
+                 continue;
+             }
+             String key = line.substring(0, line.indexOf(':')).trim();
+             String text = line.substring(line.indexOf(':') + 1);
+             setColorFromString(key, text);
+         }
+         // All done.
+         bufferedReader.close();
+     }
+     /**
+      * Sets to defaults that resemble the Borland IDE colors.
+      */
+     public void setDefaultTheme() {
+         CellAttributes color;
+         // TWindow border
+         color = new CellAttributes();
+         color.setForeColor(Color.WHITE);
+         color.setBackColor(Color.BLUE);
+         color.setBold(true);
+         colors.put("twindow.border", color);
+         // TWindow background
+         color = new CellAttributes();
+         color.setForeColor(Color.YELLOW);
+         color.setBackColor(Color.BLUE);
+         color.setBold(true);
+         colors.put("twindow.background", color);
+         // TWindow border - inactive
+         color = new CellAttributes();
+         color.setForeColor(Color.BLACK);
+         color.setBackColor(Color.BLUE);
+         color.setBold(true);
+         colors.put("twindow.border.inactive", color);
+         // TWindow background - inactive
+         color = new CellAttributes();
+         color.setForeColor(Color.YELLOW);
+         color.setBackColor(Color.BLUE);
+         color.setBold(true);
+         colors.put("twindow.background.inactive", color);
+         // TWindow border - modal
+         color = new CellAttributes();
+         color.setForeColor(Color.WHITE);
+         color.setBackColor(Color.WHITE);
+         color.setBold(true);
+         colors.put("twindow.border.modal", color);
+         // TWindow background - modal
+         color = new CellAttributes();
+         color.setForeColor(Color.BLACK);
+         color.setBackColor(Color.WHITE);
+         color.setBold(false);
+         colors.put("twindow.background.modal", color);
+         // TWindow border - modal + inactive
+         color = new CellAttributes();
+         color.setForeColor(Color.BLACK);
+         color.setBackColor(Color.WHITE);
+         color.setBold(true);
+         colors.put("twindow.border.modal.inactive", color);
+         // TWindow background - modal + inactive
+         color = new CellAttributes();
+         color.setForeColor(Color.BLACK);
+         color.setBackColor(Color.WHITE);
+         color.setBold(false);
+         colors.put("twindow.background.modal.inactive", color);
+         // TWindow border - during window movement - modal
+         color = new CellAttributes();
+         color.setForeColor(Color.GREEN);
+         color.setBackColor(Color.WHITE);
+         color.setBold(true);
+         colors.put("twindow.border.modal.windowmove", color);
+         // TWindow border - during window movement
+         color = new CellAttributes();
+         color.setForeColor(Color.GREEN);
+         color.setBackColor(Color.BLUE);
+         color.setBold(true);
+         colors.put("twindow.border.windowmove", color);
+         // TWindow background - during window movement
+         color = new CellAttributes();
+         color.setForeColor(Color.YELLOW);
+         color.setBackColor(Color.BLUE);
+         color.setBold(false);
+         colors.put("twindow.background.windowmove", color);
+         // TDesktop background
+         color = new CellAttributes();
+         color.setForeColor(Color.BLUE);
+         color.setBackColor(Color.WHITE);
+         color.setBold(false);
+         colors.put("tdesktop.background", color);
+         // TButton text
+         color = new CellAttributes();
+         color.setForeColor(Color.BLACK);
+         color.setBackColor(Color.GREEN);
+         color.setBold(false);
+         colors.put("tbutton.inactive", color);
+         color = new CellAttributes();
+         color.setForeColor(Color.CYAN);
+         color.setBackColor(Color.GREEN);
+         color.setBold(true);
+         colors.put("tbutton.active", color);
+         color = new CellAttributes();
+         color.setForeColor(Color.BLACK);
+         color.setBackColor(Color.WHITE);
+         color.setBold(true);
+         colors.put("tbutton.disabled", color);
+         color = new CellAttributes();
+         color.setForeColor(Color.YELLOW);
+         color.setBackColor(Color.GREEN);
+         color.setBold(true);
+         colors.put("tbutton.mnemonic", color);
+         color = new CellAttributes();
+         color.setForeColor(Color.YELLOW);
+         color.setBackColor(Color.GREEN);
+         color.setBold(true);
+         colors.put("tbutton.mnemonic.highlighted", color);
+         // TLabel text
+         color = new CellAttributes();
+         color.setForeColor(Color.WHITE);
+         color.setBackColor(Color.BLUE);
+         color.setBold(true);
+         colors.put("tlabel", color);
+         color = new CellAttributes();
+         color.setForeColor(Color.YELLOW);
+         color.setBackColor(Color.BLUE);
+         color.setBold(true);
+         colors.put("tlabel.mnemonic", color);
+         // TText text
+         color = new CellAttributes();
+         color.setForeColor(Color.WHITE);
+         color.setBackColor(Color.BLUE);
+         color.setBold(false);
+         colors.put("ttext", color);
+         // TField text
+         color = new CellAttributes();
+         color.setForeColor(Color.BLACK);
+         color.setBackColor(Color.WHITE);
+         color.setBold(false);
+         colors.put("tfield.inactive", color);
+         color = new CellAttributes();
+         color.setForeColor(Color.BLACK);
+         color.setBackColor(Color.CYAN);
+         color.setBold(false);
+         colors.put("tfield.active", color);
+         // TCheckBox
+         color = new CellAttributes();
+         color.setForeColor(Color.WHITE);
+         color.setBackColor(Color.BLUE);
+         color.setBold(false);
+         colors.put("tcheckbox.inactive", color);
+         color = new CellAttributes();
+         color.setForeColor(Color.YELLOW);
+         color.setBackColor(Color.BLACK);
+         color.setBold(true);
+         colors.put("tcheckbox.active", color);
+         color = new CellAttributes();
+         color.setForeColor(Color.YELLOW);
+         color.setBackColor(Color.BLUE);
+         color.setBold(true);
+         colors.put("tcheckbox.mnemonic", color);
+         color = new CellAttributes();
+         color.setForeColor(Color.RED);
+         color.setBackColor(Color.BLACK);
+         color.setBold(true);
+         colors.put("tcheckbox.mnemonic.highlighted", color);
+         // TComboBox
+         color = new CellAttributes();
+         color.setForeColor(Color.BLACK);
+         color.setBackColor(Color.WHITE);
+         color.setBold(false);
+         colors.put("tcombobox.inactive", color);
+         color = new CellAttributes();
+         color.setForeColor(Color.BLUE);
+         color.setBackColor(Color.CYAN);
+         color.setBold(false);
+         colors.put("tcombobox.active", color);
+         // TSpinner
+         color = new CellAttributes();
+         color.setForeColor(Color.BLACK);
+         color.setBackColor(Color.WHITE);
+         color.setBold(false);
+         colors.put("tspinner.inactive", color);
+         color = new CellAttributes();
+         color.setForeColor(Color.BLUE);
+         color.setBackColor(Color.CYAN);
+         color.setBold(false);
+         colors.put("tspinner.active", color);
+         // TCalendar
+         color = new CellAttributes();
+         color.setForeColor(Color.WHITE);
+         color.setBackColor(Color.BLUE);
+         color.setBold(false);
+         colors.put("tcalendar.background", color);
+         color = new CellAttributes();
+         color.setForeColor(Color.WHITE);
+         color.setBackColor(Color.BLUE);
+         color.setBold(false);
+         colors.put("tcalendar.day", color);
+         color = new CellAttributes();
+         color.setForeColor(Color.RED);
+         color.setBackColor(Color.WHITE);
+         color.setBold(false);
+         colors.put("tcalendar.day.selected", color);
+         color = new CellAttributes();
+         color.setForeColor(Color.BLUE);
+         color.setBackColor(Color.CYAN);
+         color.setBold(false);
+         colors.put("tcalendar.arrow", color);
+         color = new CellAttributes();
+         color.setForeColor(Color.WHITE);
+         color.setBackColor(Color.BLUE);
+         color.setBold(true);
+         colors.put("tcalendar.title", color);
+         // TRadioButton
+         color = new CellAttributes();
+         color.setForeColor(Color.WHITE);
+         color.setBackColor(Color.BLUE);
+         color.setBold(false);
+         colors.put("tradiobutton.inactive", color);
+         color = new CellAttributes();
+         color.setForeColor(Color.YELLOW);
+         color.setBackColor(Color.BLACK);
+         color.setBold(true);
+         colors.put("tradiobutton.active", color);
+         color = new CellAttributes();
+         color.setForeColor(Color.YELLOW);
+         color.setBackColor(Color.BLUE);
+         color.setBold(true);
+         colors.put("tradiobutton.mnemonic", color);
+         color = new CellAttributes();
+         color.setForeColor(Color.RED);
+         color.setBackColor(Color.BLACK);
+         color.setBold(true);
+         colors.put("tradiobutton.mnemonic.highlighted", color);
+         // TRadioGroup
+         color = new CellAttributes();
+         color.setForeColor(Color.WHITE);
+         color.setBackColor(Color.BLUE);
+         color.setBold(false);
+         colors.put("tradiogroup.inactive", color);
+         color = new CellAttributes();
+         color.setForeColor(Color.YELLOW);
+         color.setBackColor(Color.BLUE);
+         color.setBold(true);
+         colors.put("tradiogroup.active", color);
+         // TMenu
+         color = new CellAttributes();
+         color.setForeColor(Color.BLACK);
+         color.setBackColor(Color.WHITE);
+         color.setBold(false);
+         colors.put("tmenu", color);
+         color = new CellAttributes();
+         color.setForeColor(Color.BLACK);
+         color.setBackColor(Color.GREEN);
+         color.setBold(false);
+         colors.put("tmenu.highlighted", color);
+         color = new CellAttributes();
+         color.setForeColor(Color.RED);
+         color.setBackColor(Color.WHITE);
+         color.setBold(false);
+         colors.put("tmenu.mnemonic", color);
+         color = new CellAttributes();
+         color.setForeColor(Color.RED);
+         color.setBackColor(Color.GREEN);
+         color.setBold(false);
+         colors.put("tmenu.mnemonic.highlighted", color);
+         color = new CellAttributes();
+         color.setForeColor(Color.BLACK);
+         color.setBackColor(Color.WHITE);
+         color.setBold(true);
+         colors.put("tmenu.disabled", color);
+         // TProgressBar
+         color = new CellAttributes();
+         color.setForeColor(Color.BLUE);
+         color.setBackColor(Color.BLUE);
+         color.setBold(true);
+         colors.put("tprogressbar.complete", color);
+         color = new CellAttributes();
+         color.setForeColor(Color.WHITE);
+         color.setBackColor(Color.BLUE);
+         color.setBold(false);
+         colors.put("tprogressbar.incomplete", color);
+         // THScroller / TVScroller
+         color = new CellAttributes();
+         color.setForeColor(Color.CYAN);
+         color.setBackColor(Color.BLUE);
+         color.setBold(false);
+         colors.put("tscroller.bar", color);
+         color = new CellAttributes();
+         color.setForeColor(Color.BLUE);
+         color.setBackColor(Color.CYAN);
+         color.setBold(false);
+         colors.put("tscroller.arrows", color);
+         // TTreeView
+         color = new CellAttributes();
+         color.setForeColor(Color.WHITE);
+         color.setBackColor(Color.BLUE);
+         color.setBold(false);
+         colors.put("ttreeview", color);
+         color = new CellAttributes();
+         color.setForeColor(Color.GREEN);
+         color.setBackColor(Color.BLUE);
+         color.setBold(true);
+         colors.put("ttreeview.expandbutton", color);
+         color = new CellAttributes();
+         color.setForeColor(Color.BLACK);
+         color.setBackColor(Color.CYAN);
+         color.setBold(false);
+         colors.put("ttreeview.selected", color);
+         color = new CellAttributes();
+         color.setForeColor(Color.RED);
+         color.setBackColor(Color.BLUE);
+         color.setBold(false);
+         colors.put("ttreeview.unreadable", color);
+         color = new CellAttributes();
+         // color.setForeColor(Color.BLACK);
+         // color.setBackColor(Color.BLUE);
+         // color.setBold(true);
+         color.setForeColor(Color.WHITE);
+         color.setBackColor(Color.BLUE);
+         color.setBold(false);
+         colors.put("ttreeview.inactive", color);
+         color = new CellAttributes();
+         color.setForeColor(Color.BLACK);
+         color.setBackColor(Color.WHITE);
+         color.setBold(false);
+         colors.put("ttreeview.selected.inactive", color);
+         // TList
+         color = new CellAttributes();
+         color.setForeColor(Color.WHITE);
+         color.setBackColor(Color.BLUE);
+         color.setBold(false);
+         colors.put("tlist", color);
+         color = new CellAttributes();
+         color.setForeColor(Color.BLACK);
+         color.setBackColor(Color.CYAN);
+         color.setBold(false);
+         colors.put("tlist.selected", color);
+         color = new CellAttributes();
+         color.setForeColor(Color.BLACK);
+         color.setBackColor(Color.CYAN);
+         color.setBold(false);
+         colors.put("tlist.unreadable", color);
+         color = new CellAttributes();
+         // color.setForeColor(Color.BLACK);
+         // color.setBackColor(Color.BLUE);
+         // color.setBold(true);
+         color.setForeColor(Color.WHITE);
+         color.setBackColor(Color.BLUE);
+         color.setBold(false);
+         colors.put("tlist.inactive", color);
+         color = new CellAttributes();
+         color.setForeColor(Color.BLACK);
+         color.setBackColor(Color.WHITE);
+         color.setBold(false);
+         colors.put("tlist.selected.inactive", color);
+         // TStatusBar
+         color = new CellAttributes();
+         color.setForeColor(Color.BLACK);
+         color.setBackColor(Color.WHITE);
+         color.setBold(false);
+         colors.put("tstatusbar.text", color);
+         color = new CellAttributes();
+         color.setForeColor(Color.RED);
+         color.setBackColor(Color.WHITE);
+         color.setBold(false);
+         colors.put("tstatusbar.button", color);
+         color = new CellAttributes();
+         color.setForeColor(Color.WHITE);
+         color.setBackColor(Color.BLUE);
+         color.setBold(false);
+         colors.put("tstatusbar.selected", color);
+         // TEditor
+         color = new CellAttributes();
+         color.setForeColor(Color.WHITE);
+         color.setBackColor(Color.BLUE);
+         color.setBold(false);
+         colors.put("teditor", color);
+         // TTable
+         color = new CellAttributes();
+         color.setForeColor(Color.WHITE);
+         color.setBackColor(Color.BLUE);
+         color.setBold(false);
+         colors.put("ttable.inactive", color);
+         color = new CellAttributes();
+         color.setForeColor(Color.BLACK);
+         color.setBackColor(Color.CYAN);
+         color.setBold(false);
+         colors.put("ttable.active", color);
+         color = new CellAttributes();
+         color.setForeColor(Color.YELLOW);
+         color.setBackColor(Color.CYAN);
+         color.setBold(true);
+         colors.put("ttable.selected", color);
+         color = new CellAttributes();
+         color.setForeColor(Color.BLACK);
+         color.setBackColor(Color.WHITE);
+         color.setBold(false);
+         colors.put("ttable.label", color);
+         color = new CellAttributes();
+         color.setForeColor(Color.BLUE);
+         color.setBackColor(Color.WHITE);
+         color.setBold(false);
+         colors.put("ttable.label.selected", color);
+         color = new CellAttributes();
+         color.setForeColor(Color.WHITE);
+         color.setBackColor(Color.BLUE);
+         color.setBold(false);
+         colors.put("ttable.border", color);
+         // TSplitPane
+         color = new CellAttributes();
+         color.setForeColor(Color.WHITE);
+         color.setBackColor(Color.BLUE);
+         color.setBold(false);
+         colors.put("tsplitpane", color);
+     }
+     /**
+      * Make human-readable description of this Cell.
+      *
+      * @return displayable String
+      */
+     @Override
+     public String toString() {
+         return colors.toString();
+     }
+ }
index 0000000000000000000000000000000000000000,58be23123906b71a4e008ad15445957432a7d198..58be23123906b71a4e008ad15445957432a7d198
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,161 +1,161 @@@
+ /*
+  * Jexer - Java Text User Interface
+  *
+  * The MIT License (MIT)
+  *
+  * 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"),
+  * to deal in the Software without restriction, including without limitation
+  * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+  * and/or sell copies of the Software, and to permit persons to whom the
+  * Software is furnished to do so, subject to the following conditions:
+  *
+  * The above copyright notice and this permission notice shall be included in
+  * all copies or substantial portions of the Software.
+  *
+  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+  * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+  * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+  * DEALINGS IN THE SOFTWARE.
+  *
+  * @author Kevin Lamonte [kevin.lamonte@gmail.com]
+  * @version 1
+  */
+ package jexer.bits;
+ /**
+  * This class contains a collection of special characters used by the
+  * windowing system and the mappings from CP437 to Unicode.
+  */
+ public final class GraphicsChars {
+     // ------------------------------------------------------------------------
+     // Constants --------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * The CP437 to Unicode translation map.
+      */
+     public static final char [] CP437 = {
+         '\u2007', '\u263A', '\u263B', '\u2665',
+         '\u2666', '\u2663', '\u2660', '\u2022',
+         '\u25D8', '\u25CB', '\u25D9', '\u2642',
+         '\u2640', '\u266A', '\u266B', '\u263C',
+         // Terminus has 25B6 and 25C0 here, which I believe are better
+         // Unicode equivalents anyway.
+         // '\u25BA', '\u25C4', '\u2195', '\u203C',
+         '\u25B6', '\u25C0', '\u2195', '\u203C',
+         '\u00B6', '\u00A7', '\u25AC', '\u21A8',
+         '\u2191', '\u2193', '\u2192', '\u2190',
+         '\u221F', '\u2194', '\u25B2', '\u25BC',
+         '\u0020', '\u0021', '\"', '\u0023',
+         '\u0024', '\u0025', '\u0026', '\'',
+         '\u0028', '\u0029', '\u002a', '\u002b',
+         '\u002c', '\u002d', '\u002e', '\u002f',
+         '\u0030', '\u0031', '\u0032', '\u0033',
+         '\u0034', '\u0035', '\u0036', '\u0037',
+         '\u0038', '\u0039', '\u003a', '\u003b',
+         '\u003c', '\u003d', '\u003e', '\u003f',
+         '\u0040', '\u0041', '\u0042', '\u0043',
+         '\u0044', '\u0045', '\u0046', '\u0047',
+         '\u0048', '\u0049', '\u004a', '\u004b',
+         '\u004c', '\u004d', '\u004e', '\u004f',
+         '\u0050', '\u0051', '\u0052', '\u0053',
+         '\u0054', '\u0055', '\u0056', '\u0057',
+         '\u0058', '\u0059', '\u005a', '\u005b',
+         '\\', '\u005d', '\u005e', '\u005f',
+         '\u0060', '\u0061', '\u0062', '\u0063',
+         '\u0064', '\u0065', '\u0066', '\u0067',
+         '\u0068', '\u0069', '\u006a', '\u006b',
+         '\u006c', '\u006d', '\u006e', '\u006f',
+         '\u0070', '\u0071', '\u0072', '\u0073',
+         '\u0074', '\u0075', '\u0076', '\u0077',
+         '\u0078', '\u0079', '\u007a', '\u007b',
+         '\u007c', '\u007d', '\u007e', '\u2302',
+         '\u00c7', '\u00fc', '\u00e9', '\u00e2',
+         '\u00e4', '\u00e0', '\u00e5', '\u00e7',
+         '\u00ea', '\u00eb', '\u00e8', '\u00ef',
+         '\u00ee', '\u00ec', '\u00c4', '\u00c5',
+         '\u00c9', '\u00e6', '\u00c6', '\u00f4',
+         '\u00f6', '\u00f2', '\u00fb', '\u00f9',
+         '\u00ff', '\u00d6', '\u00dc', '\u00a2',
+         '\u00a3', '\u00a5', '\u20a7', '\u0192',
+         '\u00e1', '\u00ed', '\u00f3', '\u00fa',
+         '\u00f1', '\u00d1', '\u00aa', '\u00ba',
+         '\u00bf', '\u2310', '\u00ac', '\u00bd',
+         '\u00bc', '\u00a1', '\u00ab', '\u00bb',
+         '\u2591', '\u2592', '\u2593', '\u2502',
+         '\u2524', '\u2561', '\u2562', '\u2556',
+         '\u2555', '\u2563', '\u2551', '\u2557',
+         '\u255d', '\u255c', '\u255b', '\u2510',
+         '\u2514', '\u2534', '\u252c', '\u251c',
+         '\u2500', '\u253c', '\u255e', '\u255f',
+         '\u255a', '\u2554', '\u2569', '\u2566',
+         '\u2560', '\u2550', '\u256c', '\u2567',
+         '\u2568', '\u2564', '\u2565', '\u2559',
+         '\u2558', '\u2552', '\u2553', '\u256b',
+         '\u256a', '\u2518', '\u250c', '\u2588',
+         '\u2584', '\u258c', '\u2590', '\u2580',
+         '\u03b1', '\u00df', '\u0393', '\u03c0',
+         '\u03a3', '\u03c3', '\u00b5', '\u03c4',
+         '\u03a6', '\u0398', '\u03a9', '\u03b4',
+         '\u221e', '\u03c6', '\u03b5', '\u2229',
+         '\u2261', '\u00b1', '\u2265', '\u2264',
+         '\u2320', '\u2321', '\u00f7', '\u2248',
+         '\u00b0', '\u2219', '\u00b7', '\u221a',
+         '\u207f', '\u00b2', '\u25a0', '\u00a0'
+     };
+     public static final char HATCH                      = CP437[0xB0];
+     public static final char DOUBLE_BAR                 = CP437[0xCD];
+     public static final char BOX                        = CP437[0xFE];
+     public static final char CHECK                      = CP437[0xFB];
+     public static final char TRIPLET                    = CP437[0xF0];
+     public static final char OMEGA                      = CP437[0xEA];
+     public static final char PI                         = CP437[0xE3];
+     public static final char UPARROW                    = CP437[0x18];
+     public static final char DOWNARROW                  = CP437[0x19];
+     public static final char RIGHTARROW                 = CP437[0x1A];
+     public static final char LEFTARROW                  = CP437[0x1B];
+     public static final char SINGLE_BAR                 = CP437[0xC4];
+     public static final char BACK_ARROWHEAD             = CP437[0x11];
+     public static final char LRCORNER                   = CP437[0xD9];
+     public static final char URCORNER                   = CP437[0xBF];
+     public static final char LLCORNER                   = CP437[0xC0];
+     public static final char ULCORNER                   = CP437[0xDA];
+     public static final char DEGREE                     = CP437[0xF8];
+     public static final char PLUSMINUS                  = CP437[0xF1];
+     public static final char WINDOW_TOP                 = CP437[0xCD];
+     public static final char WINDOW_LEFT_TOP            = CP437[0xD5];
+     public static final char WINDOW_RIGHT_TOP           = CP437[0xB8];
+     public static final char WINDOW_SIDE                = CP437[0xB3];
+     public static final char WINDOW_LEFT_BOTTOM         = CP437[0xD4];
+     public static final char WINDOW_RIGHT_BOTTOM        = CP437[0xBE];
+     public static final char WINDOW_LEFT_TEE            = CP437[0xC6];
+     public static final char WINDOW_RIGHT_TEE           = CP437[0xB5];
+     public static final char WINDOW_SIDE_DOUBLE         = CP437[0xBA];
+     public static final char WINDOW_LEFT_TOP_DOUBLE     = CP437[0xC9];
+     public static final char WINDOW_RIGHT_TOP_DOUBLE    = CP437[0xBB];
+     public static final char WINDOW_LEFT_BOTTOM_DOUBLE  = CP437[0xC8];
+     public static final char WINDOW_RIGHT_BOTTOM_DOUBLE = CP437[0xBC];
+     public static final char VERTICAL_BAR               = CP437[0xB3];
+     public static final char OCTOSTAR                   = CP437[0x0F];
+     public static final char DOWNARROWLEFT              = CP437[0xDD];
+     public static final char DOWNARROWRIGHT             = CP437[0xDE];
+     // ------------------------------------------------------------------------
+     // Constructors -----------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Private constructor prevents accidental creation of this class.
+      */
+     private GraphicsChars() {
+     }
+ }
index 0000000000000000000000000000000000000000,58575b570cf0f84330aa93859e8c36d974b798e2..58575b570cf0f84330aa93859e8c36d974b798e2
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,154 +1,154 @@@
+ /*
+  * Jexer - Java Text User Interface
+  *
+  * The MIT License (MIT)
+  *
+  * 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"),
+  * to deal in the Software without restriction, including without limitation
+  * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+  * and/or sell copies of the Software, and to permit persons to whom the
+  * Software is furnished to do so, subject to the following conditions:
+  *
+  * The above copyright notice and this permission notice shall be included in
+  * all copies or substantial portions of the Software.
+  *
+  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+  * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+  * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+  * DEALINGS IN THE SOFTWARE.
+  *
+  * @author Kevin Lamonte [kevin.lamonte@gmail.com]
+  * @version 1
+  */
+ package jexer.bits;
+ /**
+  * MnemonicString is used to render a string like "&amp;File" into a
+  * highlighted 'F' and the rest of 'ile'.  To insert a literal '&amp;', use
+  * two '&amp;&amp;' characters, e.g. "&amp;File &amp;&amp; Stuff" would be
+  * "File &amp; Stuff" with the first 'F' highlighted.
+  */
+ public class MnemonicString {
+     // ------------------------------------------------------------------------
+     // Variables --------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Keyboard shortcut to activate this item.
+      */
+     private int shortcut;
+     /**
+      * Location of the highlighted character.
+      */
+     private int shortcutIdx = -1;
+     /**
+      * Screen location of the highlighted character (number of text cells
+      * required to display from the beginning to shortcutIdx).
+      */
+     private int screenShortcutIdx = -1;
+     /**
+      * The raw (uncolored) string.
+      */
+     private String rawLabel;
+     // ------------------------------------------------------------------------
+     // Constructors -----------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Public constructor.
+      *
+      * @param label widget label or title.  Label must contain a keyboard
+      * shortcut, denoted by prefixing a letter with "&amp;", e.g. "&amp;File"
+      */
+     public MnemonicString(final String label) {
+         // Setup the menu shortcut
+         StringBuilder newLabel = new StringBuilder();
+         boolean foundAmp = false;
+         boolean foundShortcut = false;
+         int scanShortcutIdx = 0;
+         int scanScreenShortcutIdx = 0;
+         for (int i = 0; i < label.length();) {
+             int c = label.codePointAt(i);
+             i += Character.charCount(c);
+             if (c == '&') {
+                 if (foundAmp) {
+                     newLabel.append('&');
+                     scanShortcutIdx++;
+                     scanScreenShortcutIdx++;
+                 } else {
+                     foundAmp = true;
+                 }
+             } else {
+                 newLabel.append(Character.toChars(c));
+                 if (foundAmp) {
+                     if (!foundShortcut) {
+                         shortcut = c;
+                         foundAmp = false;
+                         foundShortcut = true;
+                         shortcutIdx = scanShortcutIdx;
+                         screenShortcutIdx = scanScreenShortcutIdx;
+                     }
+                 } else {
+                     scanShortcutIdx++;
+                     scanScreenShortcutIdx += StringUtils.width(c);
+                 }
+             }
+         }
+         this.rawLabel = newLabel.toString();
+     }
+     // ------------------------------------------------------------------------
+     // MnemonicString ---------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Get the keyboard shortcut character.
+      *
+      * @return the highlighted character
+      */
+     public int getShortcut() {
+         return shortcut;
+     }
+     /**
+      * Get location of the highlighted character.
+      *
+      * @return location of the highlighted character
+      */
+     public int getShortcutIdx() {
+         return shortcutIdx;
+     }
+     /**
+      * Get the screen location of the highlighted character.
+      *
+      * @return the number of text cells required to display from the
+      * beginning of the label to shortcutIdx
+      */
+     public int getScreenShortcutIdx() {
+         return screenShortcutIdx;
+     }
+     /**
+      * Get the raw (uncolored) string.
+      *
+      * @return the raw (uncolored) string
+      */
+     public String getRawLabel() {
+         return rawLabel;
+     }
+ }
index 0000000000000000000000000000000000000000,fffce206875cf663480d2041aac121f88b58d01a..fffce206875cf663480d2041aac121f88b58d01a
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,498 +1,498 @@@
+ /*
+  * Jexer - Java Text User Interface
+  *
+  * The MIT License (MIT)
+  *
+  * 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"),
+  * to deal in the Software without restriction, including without limitation
+  * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+  * and/or sell copies of the Software, and to permit persons to whom the
+  * Software is furnished to do so, subject to the following conditions:
+  *
+  * The above copyright notice and this permission notice shall be included in
+  * all copies or substantial portions of the Software.
+  *
+  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+  * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+  * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+  * DEALINGS IN THE SOFTWARE.
+  *
+  * @author Kevin Lamonte [kevin.lamonte@gmail.com]
+  * @version 1
+  */
+ package jexer.bits;
+ import java.util.List;
+ import java.util.ArrayList;
+ /**
+  * StringUtils contains methods to:
+  *
+  *    - Convert one or more long lines of strings into justified text
+  *      paragraphs.
+  *
+  *    - Unescape C0 control codes.
+  *
+  *    - Read/write a line of RFC4180 comma-separated values strings to/from a
+  *      list of strings.
+  */
+ public class StringUtils {
+     /**
+      * Left-justify a string into a list of lines.
+      *
+      * @param str the string
+      * @param n the maximum number of characters in a line
+      * @return the list of lines
+      */
+     public static List<String> left(final String str, final int n) {
+         List<String> result = new ArrayList<String>();
+         /*
+          * General procedure:
+          *
+          *   1. Split on '\n' into paragraphs.
+          *
+          *   2. Scan each line, noting the position of the last
+          *      beginning-of-a-word.
+          *
+          *   3. Chop at the last #2 if the next beginning-of-a-word exceeds
+          *      n.
+          *
+          *   4. Return the lines.
+          */
+         String [] rawLines = str.split("\n");
+         for (int i = 0; i < rawLines.length; i++) {
+             StringBuilder line = new StringBuilder();
+             StringBuilder word = new StringBuilder();
+             boolean inWord = false;
+             for (int j = 0; j < rawLines[i].length(); j++) {
+                 char ch = rawLines[i].charAt(j);
+                 if ((ch == ' ') || (ch == '\t')) {
+                     if (inWord == true) {
+                         // We have just transitioned from a word to
+                         // whitespace.  See if we have enough space to add
+                         // the word to the line.
+                         if (width(word.toString()) + width(line.toString()) > n) {
+                             // This word will exceed the line length.  Wrap
+                             // at it instead.
+                             result.add(line.toString());
+                             line = new StringBuilder();
+                         }
+                         if ((word.toString().startsWith(" "))
+                             && (width(line.toString()) == 0)
+                         ) {
+                             line.append(word.substring(1));
+                         } else {
+                             line.append(word);
+                         }
+                         word = new StringBuilder();
+                         word.append(ch);
+                         inWord = false;
+                     } else {
+                         // We are in the whitespace before another word.  Do
+                         // nothing.
+                     }
+                 } else {
+                     if (inWord == true) {
+                         // We are appending to a word.
+                         word.append(ch);
+                     } else {
+                         // We have transitioned from whitespace to a word.
+                         word.append(ch);
+                         inWord = true;
+                     }
+                 }
+             } // for (int j = 0; j < rawLines[i].length(); j++)
+             if (width(word.toString()) + width(line.toString()) > n) {
+                 // This word will exceed the line length.  Wrap at it
+                 // instead.
+                 result.add(line.toString());
+                 line = new StringBuilder();
+             }
+             if ((word.toString().startsWith(" "))
+                 && (width(line.toString()) == 0)
+             ) {
+                 line.append(word.substring(1));
+             } else {
+                 line.append(word);
+             }
+             result.add(line.toString());
+         } // for (int i = 0; i < rawLines.length; i++) {
+         return result;
+     }
+     /**
+      * Right-justify a string into a list of lines.
+      *
+      * @param str the string
+      * @param n the maximum number of characters in a line
+      * @return the list of lines
+      */
+     public static List<String> right(final String str, final int n) {
+         List<String> result = new ArrayList<String>();
+         /*
+          * Same as left(), but preceed each line with spaces to make it n
+          * chars long.
+          */
+         List<String> lines = left(str, n);
+         for (String line: lines) {
+             StringBuilder sb = new StringBuilder();
+             for (int i = 0; i < n - width(line); i++) {
+                 sb.append(' ');
+             }
+             sb.append(line);
+             result.add(sb.toString());
+         }
+         return result;
+     }
+     /**
+      * Center a string into a list of lines.
+      *
+      * @param str the string
+      * @param n the maximum number of characters in a line
+      * @return the list of lines
+      */
+     public static List<String> center(final String str, final int n) {
+         List<String> result = new ArrayList<String>();
+         /*
+          * Same as left(), but preceed/succeed each line with spaces to make
+          * it n chars long.
+          */
+         List<String> lines = left(str, n);
+         for (String line: lines) {
+             StringBuilder sb = new StringBuilder();
+             int l = (n - width(line)) / 2;
+             int r = n - width(line) - l;
+             for (int i = 0; i < l; i++) {
+                 sb.append(' ');
+             }
+             sb.append(line);
+             for (int i = 0; i < r; i++) {
+                 sb.append(' ');
+             }
+             result.add(sb.toString());
+         }
+         return result;
+     }
+     /**
+      * Fully-justify a string into a list of lines.
+      *
+      * @param str the string
+      * @param n the maximum number of characters in a line
+      * @return the list of lines
+      */
+     public static List<String> full(final String str, final int n) {
+         List<String> result = new ArrayList<String>();
+         /*
+          * Same as left(), but insert spaces between words to make each line
+          * n chars long.  The "algorithm" here is pretty dumb: it performs a
+          * split on space and then re-inserts multiples of n between words.
+          */
+         List<String> lines = left(str, n);
+         for (int lineI = 0; lineI < lines.size() - 1; lineI++) {
+             String line = lines.get(lineI);
+             String [] words = line.split(" ");
+             if (words.length > 1) {
+                 int charCount = 0;
+                 for (int i = 0; i < words.length; i++) {
+                     charCount += words[i].length();
+                 }
+                 int spaceCount = n - charCount;
+                 int q = spaceCount / (words.length - 1);
+                 int r = spaceCount % (words.length - 1);
+                 StringBuilder sb = new StringBuilder();
+                 for (int i = 0; i < words.length - 1; i++) {
+                     sb.append(words[i]);
+                     for (int j = 0; j < q; j++) {
+                         sb.append(' ');
+                     }
+                     if (r > 0) {
+                         sb.append(' ');
+                         r--;
+                     }
+                 }
+                 for (int j = 0; j < r; j++) {
+                     sb.append(' ');
+                 }
+                 sb.append(words[words.length - 1]);
+                 result.add(sb.toString());
+             } else {
+                 result.add(line);
+             }
+         }
+         if (lines.size() > 0) {
+             result.add(lines.get(lines.size() - 1));
+         }
+         return result;
+     }
+     /**
+      * Convert raw strings into escaped strings that be splatted on the
+      * screen.
+      *
+      * @param str the string
+      * @return a string that can be passed into Screen.putStringXY()
+      */
+     public static String unescape(final String str) {
+         StringBuilder sb = new StringBuilder();
+         for (int i = 0; i < str.length(); i++) {
+             char ch = str.charAt(i);
+             if ((ch < 0x20) || (ch == 0x7F)) {
+                 switch (ch) {
+                 case '\b':
+                     sb.append("\\b");
+                     continue;
+                 case '\f':
+                     sb.append("\\f");
+                     continue;
+                 case '\n':
+                     sb.append("\\n");
+                     continue;
+                 case '\r':
+                     sb.append("\\r");
+                     continue;
+                 case '\t':
+                     sb.append("\\t");
+                     continue;
+                 case 0x7f:
+                     sb.append("^?");
+                     continue;
+                 default:
+                     sb.append(' ');
+                     continue;
+                 }
+             }
+             sb.append(ch);
+         }
+         return sb.toString();
+     }
+     /**
+      * Read a line of RFC4180 comma-separated values (CSV) into a list of
+      * strings.
+      *
+      * @param line the CSV line, with or without without line terminators
+      * @return the list of strings
+      */
+     public static List<String> fromCsv(final String line) {
+         List<String> result = new ArrayList<String>();
+         StringBuilder str = new StringBuilder();
+         boolean quoted = false;
+         boolean fieldQuoted = false;
+         for (int i = 0; i < line.length(); i++) {
+             char ch = line.charAt(i);
+             /*
+             System.err.println("ch '" + ch + "' str '" + str + "' " +
+                 " fieldQuoted " + fieldQuoted + " quoted " + quoted);
+              */
+             if (ch == ',') {
+                 if (fieldQuoted && quoted) {
+                     // Terminating a quoted field.
+                     result.add(str.toString());
+                     str = new StringBuilder();
+                     quoted = false;
+                     fieldQuoted = false;
+                 } else if (fieldQuoted) {
+                     // Still waiting to see the terminating quote for this
+                     // field.
+                     str.append(ch);
+                 } else if (quoted) {
+                     // An unmatched double-quote and comma.  This should be
+                     // an invalid sequence.  We will treat it as a quote
+                     // terminating the field.
+                     str.append('\"');
+                     result.add(str.toString());
+                     str = new StringBuilder();
+                     quoted = false;
+                     fieldQuoted = false;
+                 } else {
+                     // A field separator.
+                     result.add(str.toString());
+                     str = new StringBuilder();
+                     quoted = false;
+                     fieldQuoted = false;
+                 }
+                 continue;
+             }
+             if (ch == '\"') {
+                 if ((str.length() == 0) && (!fieldQuoted)) {
+                     // The opening quote to a quoted field.
+                     fieldQuoted = true;
+                 } else if (quoted) {
+                     // This is a double-quote.
+                     str.append('\"');
+                     quoted = false;
+                 } else {
+                     // This is the beginning of a quote.
+                     quoted = true;
+                 }
+                 continue;
+             }
+             // Normal character, pass it on.
+             str.append(ch);
+         }
+         // Include the final field.
+         result.add(str.toString());
+         return result;
+     }
+     /**
+      * Write a list of strings to on line of RFC4180 comma-separated values
+      * (CSV).
+      *
+      * @param list the list of strings
+      * @return the CSV line, without any line terminators
+      */
+     public static String toCsv(final List<String> list) {
+         StringBuilder result = new StringBuilder();
+         int i = 0;
+         for (String str: list) {
+             if (!str.contains("\"") && !str.contains(",")) {
+                 // Just append the string with a comma.
+                 result.append(str);
+             } else if (!str.contains("\"") && str.contains(",")) {
+                 // Contains commas, but no quotes.  Just double-quote it.
+                 result.append("\"");
+                 result.append(str);
+                 result.append("\"");
+             } else if (str.contains("\"")) {
+                 // Contains quotes and maybe commas.  Double-quote it and
+                 // replace quotes inside.
+                 result.append("\"");
+                 for (int j = 0; j < str.length(); j++) {
+                     char ch = str.charAt(j);
+                     result.append(ch);
+                     if (ch == '\"') {
+                         result.append("\"");
+                     }
+                 }
+                 result.append("\"");
+             }
+             if (i < list.size() - 1) {
+                 result.append(",");
+             }
+             i++;
+         }
+         return result.toString();
+     }
+     /**
+      * Determine display width of a Unicode code point.
+      *
+      * @param ch the code point, can be char
+      * @return the number of text cell columns required to display this code
+      * point, one of 0, 1, or 2
+      */
+     public static int width(final int ch) {
+         /*
+          * This routine is a modified version of mk_wcwidth() available
+          * at: http://www.cl.cam.ac.uk/~mgk25/ucs/wcwidth.c
+          *
+          * The combining characters list has been omitted from this
+          * implementation.  Hopefully no users will be impacted.
+          */
+         // 8-bit control characters: width 0
+         if (ch == 0) {
+             return 0;
+         }
+         if ((ch < 32) || ((ch >= 0x7f) && (ch < 0xa0))) {
+             return 0;
+         }
+         // All others: either 1 or 2
+         if ((ch >= 0x1100)
+             && ((ch <= 0x115f)
+                 // Hangul Jamo init. consonants
+                 || (ch == 0x2329)
+                 || (ch == 0x232a)
+                 // CJK ... Yi
+                 || ((ch >= 0x2e80) && (ch <= 0xa4cf) && (ch != 0x303f))
+                 // Hangul Syllables
+                 || ((ch >= 0xac00) && (ch <= 0xd7a3))
+                 // CJK Compatibility Ideographs
+                 || ((ch >= 0xf900) && (ch <= 0xfaff))
+                 // Vertical forms
+                 || ((ch >= 0xfe10) && (ch <= 0xfe19))
+                 // CJK Compatibility Forms
+                 || ((ch >= 0xfe30) && (ch <= 0xfe6f))
+                 // Fullwidth Forms
+                 || ((ch >= 0xff00) && (ch <= 0xff60))
+                 || ((ch >= 0xffe0) && (ch <= 0xffe6))
+                 || ((ch >= 0x20000) && (ch <= 0x2fffd))
+                 || ((ch >= 0x30000) && (ch <= 0x3fffd))
+                 // emoji
+                 || ((ch >= 0x1f004) && (ch <= 0x1fffd))
+             )
+         ) {
+             return 2;
+         }
+         return 1;
+     }
+     /**
+      * Determine display width of a string.  This ASSUMES that no characters
+      * are combining.  Hopefully no users will be impacted.
+      *
+      * @param str the string
+      * @return the number of text cell columns required to display this string
+      */
+     public static int width(final String str) {
+         int n = 0;
+         for (int i = 0; i < str.length();) {
+             int ch = str.codePointAt(i);
+             n += width(ch);
+             i += Character.charCount(ch);
+         }
+         return n;
+     }
+     /**
+      * Check if character is in the CJK range.
+      *
+      * @param ch character to check
+      * @return true if this character is in the CJK range
+      */
+     public static boolean isCjk(final int ch) {
+         return ((ch >= 0x2e80) && (ch <= 0x9fff));
+     }
+     /**
+      * Check if character is in the emoji range.
+      *
+      * @param ch character to check
+      * @return true if this character is in the emoji range
+      */
+     public static boolean isEmoji(final int ch) {
+         return ((ch >= 0x1f004) && (ch <= 0x1fffd));
+     }
+ }
index 0000000000000000000000000000000000000000,cffe10e145cb8a95eafe5445165ba54c63d76241..cffe10e145cb8a95eafe5445165ba54c63d76241
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,34 +1,34 @@@
+ /*
+  * Jexer - Java Text User Interface
+  *
+  * The MIT License (MIT)
+  *
+  * 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"),
+  * to deal in the Software without restriction, including without limitation
+  * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+  * and/or sell copies of the Software, and to permit persons to whom the
+  * Software is furnished to do so, subject to the following conditions:
+  *
+  * The above copyright notice and this permission notice shall be included in
+  * all copies or substantial portions of the Software.
+  *
+  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+  * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+  * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+  * DEALINGS IN THE SOFTWARE.
+  *
+  * @author Kevin Lamonte [kevin.lamonte@gmail.com]
+  * @version 1
+  */
+ /**
+  * Low-level data objects and utility functions that don't warrant their own
+  * separate package.
+  */
+ package jexer.bits;
index 0000000000000000000000000000000000000000,97088d215fcb7c228f6a065c1cb86cfec188f9b4..97088d215fcb7c228f6a065c1cb86cfec188f9b4
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,69 +1,69 @@@
+ /*
+  * Jexer - Java Text User Interface
+  *
+  * The MIT License (MIT)
+  *
+  * 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"),
+  * to deal in the Software without restriction, including without limitation
+  * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+  * and/or sell copies of the Software, and to permit persons to whom the
+  * Software is furnished to do so, subject to the following conditions:
+  *
+  * The above copyright notice and this permission notice shall be included in
+  * all copies or substantial portions of the Software.
+  *
+  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+  * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+  * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+  * DEALINGS IN THE SOFTWARE.
+  *
+  * @author Kevin Lamonte [kevin.lamonte@gmail.com]
+  * @version 1
+  */
+ package jexer.demos;
+ import jexer.TApplication;
+ /**
+  * This class is the main driver for a simple demonstration of Jexer's
+  * capabilities.
+  */
+ public class Demo1 {
+     /**
+      * Main entry point.
+      *
+      * @param args Command line arguments
+      */
+     public static void main(final String [] args) {
+         try {
+             // Swing is the default backend on Windows unless explicitly
+             // overridden by jexer.Swing.
+             TApplication.BackendType backendType = TApplication.BackendType.XTERM;
+             if (System.getProperty("os.name").startsWith("Windows")) {
+                 backendType = TApplication.BackendType.SWING;
+             }
+             if (System.getProperty("os.name").startsWith("Mac")) {
+                 backendType = TApplication.BackendType.SWING;
+             }
+             if (System.getProperty("jexer.Swing") != null) {
+                 if (System.getProperty("jexer.Swing", "false").equals("true")) {
+                     backendType = TApplication.BackendType.SWING;
+                 } else {
+                     backendType = TApplication.BackendType.XTERM;
+                 }
+             }
+             DemoApplication app = new DemoApplication(backendType);
+             (new Thread(app)).start();
+         } catch (Exception e) {
+             e.printStackTrace();
+         }
+     }
+ }
index 0000000000000000000000000000000000000000,2db03ceda44afad1db3a09bb0a216975374fcac0..2db03ceda44afad1db3a09bb0a216975374fcac0
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,99 +1,99 @@@
+ /*
+  * Jexer - Java Text User Interface
+  *
+  * The MIT License (MIT)
+  *
+  * 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"),
+  * to deal in the Software without restriction, including without limitation
+  * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+  * and/or sell copies of the Software, and to permit persons to whom the
+  * Software is furnished to do so, subject to the following conditions:
+  *
+  * The above copyright notice and this permission notice shall be included in
+  * all copies or substantial portions of the Software.
+  *
+  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+  * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+  * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+  * DEALINGS IN THE SOFTWARE.
+  *
+  * @author Kevin Lamonte [kevin.lamonte@gmail.com]
+  * @version 1
+  */
+ package jexer.demos;
+ import java.net.ServerSocket;
+ import java.net.Socket;
+ import java.text.MessageFormat;
+ import java.util.ResourceBundle;
+ import jexer.net.TelnetServerSocket;
+ /**
+  * This class is the main driver for a simple demonstration of Jexer's
+  * capabilities.  Rather than run locally, it serves a Jexer UI over a TCP
+  * port.
+  */
+ public class Demo2 {
+     /**
+      * Translated strings.
+      */
+     private static final ResourceBundle i18n = ResourceBundle.getBundle(Demo2.class.getName());
+     /**
+      * Main entry point.
+      *
+      * @param args Command line arguments
+      */
+     public static void main(final String [] args) {
+         ServerSocket server = null;
+         try {
+             if (args.length == 0) {
+                 System.err.println(i18n.getString("usageString"));
+                 return;
+             }
+             int port = Integer.parseInt(args[0]);
+             server = new TelnetServerSocket(port);
+             while (true) {
+                 Socket socket = server.accept();
+                 System.out.println(MessageFormat.
+                     format(i18n.getString("newConnection"), socket));
+                 DemoApplication app = new DemoApplication(socket.getInputStream(),
+                     socket.getOutputStream());
+                 (new Thread(app)).start();
+                 Thread.sleep(500);
+                 System.out.println(MessageFormat.
+                     format(i18n.getString("terminal"),
+                     ((jexer.net.TelnetInputStream) socket.getInputStream()).
+                         getTerminalType()));
+                 System.out.println(MessageFormat.
+                     format(i18n.getString("username"),
+                     ((jexer.net.TelnetInputStream) socket.getInputStream()).
+                         getUsername()));
+                 System.out.println(MessageFormat.
+                     format(i18n.getString("language"),
+                     ((jexer.net.TelnetInputStream) socket.getInputStream()).
+                         getLanguage()));
+             }
+         } catch (Exception e) {
+             e.printStackTrace();
+         } finally {
+             if (server != null) {
+                 try {
+                     server.close();
+                 } catch (Exception e) {
+                     // SQUASH
+                 }
+             }
+         }
+     }
+ }
index 0000000000000000000000000000000000000000,fa2b98fcddb8fda75177fc63cbc380e0afd43c9d..fa2b98fcddb8fda75177fc63cbc380e0afd43c9d
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,5 +1,5 @@@
+ usageString=USAGE: java -cp jexer.jar jexer.demos.Demo2 port
+ newConnection=New connection: {0}
+ username=\ \ \ username: {0}
+ language=\ \ \ language: {0}
+ terminal=\ \ \ terminal: {0}
index 0000000000000000000000000000000000000000,f370f8f504a32eaade28bdccb7939949a0fb3798..f370f8f504a32eaade28bdccb7939949a0fb3798
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,57 +1,57 @@@
+ /*
+  * Jexer - Java Text User Interface
+  *
+  * The MIT License (MIT)
+  *
+  * 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"),
+  * to deal in the Software without restriction, including without limitation
+  * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+  * and/or sell copies of the Software, and to permit persons to whom the
+  * Software is furnished to do so, subject to the following conditions:
+  *
+  * The above copyright notice and this permission notice shall be included in
+  * all copies or substantial portions of the Software.
+  *
+  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+  * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+  * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+  * DEALINGS IN THE SOFTWARE.
+  *
+  * @author Kevin Lamonte [kevin.lamonte@gmail.com]
+  * @version 1
+  */
+ package jexer.demos;
+ import java.io.*;
+ /**
+  * This class is the main driver for a simple demonstration of Jexer's
+  * capabilities.  This one passes separate Reader/Writer to TApplication,
+  * which will behave quite badly due to System.in/out not being in raw mode.
+  */
+ public class Demo3 {
+     /**
+      * Main entry point.
+      *
+      * @param args Command line arguments
+      */
+     public static void main(final String [] args) {
+         try {
+             DemoApplication app = new DemoApplication(System.in,
+                 new InputStreamReader(System.in, "UTF-8"),
+                 new PrintWriter(new OutputStreamWriter(System.out, "UTF-8")),
+                 true);
+             (new Thread(app)).start();
+         } catch (Exception e) {
+             e.printStackTrace();
+         }
+     }
+ }
index 0000000000000000000000000000000000000000,edbc2c0024311bedb00c4fdecbdcb872b044668d..edbc2c0024311bedb00c4fdecbdcb872b044668d
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,69 +1,69 @@@
+ /*
+  * Jexer - Java Text User Interface
+  *
+  * The MIT License (MIT)
+  *
+  * 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"),
+  * to deal in the Software without restriction, including without limitation
+  * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+  * and/or sell copies of the Software, and to permit persons to whom the
+  * Software is furnished to do so, subject to the following conditions:
+  *
+  * The above copyright notice and this permission notice shall be included in
+  * all copies or substantial portions of the Software.
+  *
+  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+  * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+  * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+  * DEALINGS IN THE SOFTWARE.
+  *
+  * @author Kevin Lamonte [kevin.lamonte@gmail.com]
+  * @version 1
+  */
+ package jexer.demos;
+ import jexer.*;
+ /**
+  * This class is the main driver for a simple demonstration of Jexer's
+  * capabilities.  This one shows TDesktop and TWindow API details.
+  */
+ public class Demo4 {
+     /**
+      * Main entry point.
+      *
+      * @param args Command line arguments
+      */
+     public static void main(final String [] args) {
+         try {
+             // Swing is the default backend on Windows unless explicitly
+             // overridden by jexer.Swing.
+             TApplication.BackendType backendType = TApplication.BackendType.XTERM;
+             if (System.getProperty("os.name").startsWith("Windows")) {
+                 backendType = TApplication.BackendType.SWING;
+             }
+             if (System.getProperty("os.name").startsWith("Mac")) {
+                 backendType = TApplication.BackendType.SWING;
+             }
+             if (System.getProperty("jexer.Swing") != null) {
+                 if (System.getProperty("jexer.Swing", "false").equals("true")) {
+                     backendType = TApplication.BackendType.SWING;
+                 } else {
+                     backendType = TApplication.BackendType.XTERM;
+                 }
+             }
+             DesktopDemoApplication app = new DesktopDemoApplication(backendType);
+             (new Thread(app)).start();
+         } catch (Exception e) {
+             e.printStackTrace();
+         }
+     }
+ }
index 0000000000000000000000000000000000000000,e63abc1b848c37d8a9ea7b06346eefe875af7f31..e63abc1b848c37d8a9ea7b06346eefe875af7f31
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,224 +1,224 @@@
+ /*
+  * Jexer - Java Text User Interface
+  *
+  * The MIT License (MIT)
+  *
+  * 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"),
+  * to deal in the Software without restriction, including without limitation
+  * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+  * and/or sell copies of the Software, and to permit persons to whom the
+  * Software is furnished to do so, subject to the following conditions:
+  *
+  * The above copyright notice and this permission notice shall be included in
+  * all copies or substantial portions of the Software.
+  *
+  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+  * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+  * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+  * DEALINGS IN THE SOFTWARE.
+  *
+  * @author Kevin Lamonte [kevin.lamonte@gmail.com]
+  * @version 1
+  */
+ package jexer.demos;
+ import java.awt.Font;
+ import java.awt.event.WindowEvent;
+ import java.awt.event.WindowListener;
+ import java.util.ResourceBundle;
+ import javax.swing.JFrame;
+ import javax.swing.JPanel;
+ import javax.swing.JSplitPane;
+ import jexer.backend.SwingBackend;
+ /**
+  * This class is the main driver for a simple demonstration of Jexer's
+  * capabilities.  It shows two Swing demo applications running in the same
+  * Swing UI.
+  */
+ public class Demo5 implements WindowListener {
+     /**
+      * Translated strings.
+      */
+     private static final ResourceBundle i18n = ResourceBundle.getBundle(Demo5.class.getName());
+     // ------------------------------------------------------------------------
+     // Variables --------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * The first demo application instance.
+      */
+     DemoApplication app1 = null;
+     /**
+      * The second demo application instance.
+      */
+     DemoApplication app2 = null;
+     // ------------------------------------------------------------------------
+     // WindowListener ---------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Pass window events into the event queue.
+      *
+      * @param event window event received
+      */
+     public void windowActivated(final WindowEvent event) {
+         // Ignore
+     }
+     /**
+      * Pass window events into the event queue.
+      *
+      * @param event window event received
+      */
+     public void windowClosed(final WindowEvent event) {
+         // Ignore
+     }
+     /**
+      * Pass window events into the event queue.
+      *
+      * @param event window event received
+      */
+     public void windowClosing(final WindowEvent event) {
+         if (app1 != null) {
+             app1.exit();
+         }
+         if (app2 != null) {
+             app2.exit();
+         }
+     }
+     /**
+      * Pass window events into the event queue.
+      *
+      * @param event window event received
+      */
+     public void windowDeactivated(final WindowEvent event) {
+         // Ignore
+     }
+     /**
+      * Pass window events into the event queue.
+      *
+      * @param event window event received
+      */
+     public void windowDeiconified(final WindowEvent event) {
+         // Ignore
+     }
+     /**
+      * Pass window events into the event queue.
+      *
+      * @param event window event received
+      */
+     public void windowIconified(final WindowEvent event) {
+         // Ignore
+     }
+     /**
+      * Pass window events into the event queue.
+      *
+      * @param event window event received
+      */
+     public void windowOpened(final WindowEvent event) {
+         // Ignore
+     }
+     // ------------------------------------------------------------------------
+     // Demo5 ------------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Run two demo applications in separate panes.
+      */
+     private void addApplications() {
+         /*
+          * In this demo we will create two swing panels with two
+          * independently running applications, each with a different font
+          * size.
+          */
+         /*
+          * First we create a panel to put it on.  We need this to pass to
+          * SwingBackend's constructor, so that it knows not to create a new
+          * frame.
+          */
+         JPanel app1Panel = new JPanel();
+         /*
+          * Next, we create the Swing backend.  The "listener" (second
+          * argument, set to null) is what the backend wakes up on every event
+          * received.  Typically this is the TApplication.  TApplication sets
+          * it in its constructor, so we can pass null here and be fine.
+          */
+         SwingBackend app1Backend = new SwingBackend(app1Panel, null,
+             80, 25, 16);
+         // Now that we have the backend, construct the TApplication.
+         app1 = new DemoApplication(app1Backend);
+         /*
+          * The second panel is the same sequence, except that we also change
+          * the font from the default Terminus to JVM monospaced.
+          */
+         JPanel app2Panel = new JPanel();
+         SwingBackend app2Backend = new SwingBackend(app2Panel, null,
+             80, 25, 18);
+         app2 = new DemoApplication(app2Backend);
+         Font font = new Font(Font.MONOSPACED, Font.PLAIN, 18);
+         app2Backend.setFont(font);
+         /*
+          * Now that the applications are ready, spin them off on their
+          * threads.
+          */
+         (new Thread(app1)).start();
+         (new Thread(app2)).start();
+         /*
+          * The rest of this is standard Swing.  Set up a frame, a split pane,
+          * put each of the panels on it, and make it visible.
+          */
+         JFrame frame = new JFrame();
+         frame.setDefaultCloseOperation(JFrame.DISPOSE_ON_CLOSE);
+         frame.addWindowListener(this);
+         JSplitPane mainPane = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT,
+             app1Panel, app2Panel);
+         mainPane.setOneTouchExpandable(true);
+         mainPane.setDividerLocation(500);
+         mainPane.setDividerSize(6);
+         mainPane.setBorder(null);
+         frame.setContentPane(mainPane);
+         frame.setTitle(i18n.getString("frameTitle"));
+         frame.setSize(1000, 640);
+         frame.setVisible(true);
+     }
+     /**
+      * Main entry point.
+      *
+      * @param args Command line arguments
+      */
+     public static void main(final String [] args) {
+         try {
+             Demo5 demo = new Demo5();
+             demo.addApplications();
+         } catch (Exception e) {
+             e.printStackTrace();
+         }
+     }
+ }
index 0000000000000000000000000000000000000000,56b419dfbfd79278a31609e4f4aae4f1c24d753a..56b419dfbfd79278a31609e4f4aae4f1c24d753a
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,1 +1,1 @@@
+ frameTitle=Two Jexer Apps In One Swing UI
index 0000000000000000000000000000000000000000,db0b5c9d3f53a7083d4a47a99930ebcec9058d3b..db0b5c9d3f53a7083d4a47a99930ebcec9058d3b
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,148 +1,148 @@@
+ /*
+  * Jexer - Java Text User Interface
+  *
+  * The MIT License (MIT)
+  *
+  * 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"),
+  * to deal in the Software without restriction, including without limitation
+  * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+  * and/or sell copies of the Software, and to permit persons to whom the
+  * Software is furnished to do so, subject to the following conditions:
+  *
+  * The above copyright notice and this permission notice shall be included in
+  * all copies or substantial portions of the Software.
+  *
+  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+  * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+  * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+  * DEALINGS IN THE SOFTWARE.
+  *
+  * @author Kevin Lamonte [kevin.lamonte@gmail.com]
+  * @version 1
+  */
+ package jexer.demos;
+ import java.util.ResourceBundle;
+ import jexer.TApplication;
+ import jexer.backend.*;
+ import jexer.demos.DemoApplication;
+ /**
+  * This class shows off the use of MultiBackend and MultiScreen.
+  */
+ public class Demo6 {
+     /**
+      * Translated strings.
+      */
+     private static final ResourceBundle i18n = ResourceBundle.getBundle(Demo6.class.getName());
+     // ------------------------------------------------------------------------
+     // Demo6 ------------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Main entry point.
+      *
+      * @param args Command line arguments
+      */
+     public static void main(final String [] args) {
+         try {
+             /*
+              * In this demo we will create two applications spanning three
+              * screens.  One of the applications will have both an ECMA48
+              * screen and a Swing screen, with all I/O mirrored between them.
+              * The second application will have a Swing screen containing a
+              * window showing the first application, also mirroring I/O
+              * between the window and the other two screens.
+              */
+             /*
+              * We create the first screen and use it to establish a
+              * MultiBackend.
+              */
+             ECMA48Backend ecmaBackend = new ECMA48Backend();
+             MultiBackend multiBackend = new MultiBackend(ecmaBackend);
+             /*
+              * Now we create the first application (a standard demo).
+              */
+             DemoApplication demoApp = new DemoApplication(multiBackend);
+             /*
+              * We will need the width and height of the ECMA48 screen, so get
+              * the Screen reference now.
+              */
+             Screen multiScreen = multiBackend.getScreen();
+             /*
+              * Now we create the second screen (backend) for the first
+              * application.  It will be the same size as the ECMA48 screen,
+              * with a font size of 16 points.
+              */
+             SwingBackend swingBackend = new SwingBackend(multiScreen.getWidth(),
+                 multiScreen.getHeight(), 16);
+             /*
+              * Add this screen to the MultiBackend, and at this point we have
+              * one demo application spanning two physical screens.
+              */
+             multiBackend.addBackend(swingBackend);
+             multiBackend.setListener(demoApp);
+             /*
+              * Time for the second application.  This one will have a single
+              * window mirroring the contents of the first application.  Let's
+              * make it a little larger than the first application's
+              * width/height.
+              */
+             int width = multiScreen.getWidth();
+             int height = multiScreen.getHeight();
+             /*
+              * Make a new Swing window for the second application.
+              */
+             SwingBackend monitorBackend = new SwingBackend(width + 5,
+                 height + 5, 16);
+             /*
+              * Setup the second application, give it the basic file and
+              * window menus.
+              */
+             TApplication monitor = new TApplication(monitorBackend);
+             monitor.addToolMenu();
+             monitor.addFileMenu();
+             monitor.addWindowMenu();
+             /*
+              * Now add the third screen to the first application.  We want to
+              * change the object it locks on in its draw() method to the
+              * MultiScreen, that will dramatically reduce (not totally
+              * eliminate) screen tearing/artifacts.
+              */
+             TWindowBackend windowBackend = new TWindowBackend(demoApp,
+                 monitor, i18n.getString("monitorWindow"),
+                 width + 2, height + 2);
+             windowBackend.setDrawLock(multiScreen);
+             windowBackend.setOtherApplication(demoApp);
+             multiBackend.addBackend(windowBackend);
+             /*
+              * Three screens, two applications: spin them up!
+              */
+             (new Thread(demoApp)).start();
+             (new Thread(monitor)).start();
+         } catch (Exception e) {
+             e.printStackTrace();
+         }
+     }
+ }
index 0000000000000000000000000000000000000000,450829a04df4a9c40031f3534f77b9fef0862305..450829a04df4a9c40031f3534f77b9fef0862305
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,1 +1,1 @@@
+ monitorWindow=Monitor Window
index 0000000000000000000000000000000000000000,5f923479719aa7449d7aea31e8a60a0baffaf9d8..5f923479719aa7449d7aea31e8a60a0baffaf9d8
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,102 +1,102 @@@
+ /*
+  * Jexer - Java Text User Interface
+  *
+  * The MIT License (MIT)
+  *
+  * 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"),
+  * to deal in the Software without restriction, including without limitation
+  * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+  * and/or sell copies of the Software, and to permit persons to whom the
+  * Software is furnished to do so, subject to the following conditions:
+  *
+  * The above copyright notice and this permission notice shall be included in
+  * all copies or substantial portions of the Software.
+  *
+  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+  * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+  * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+  * DEALINGS IN THE SOFTWARE.
+  *
+  * @author Kevin Lamonte [kevin.lamonte@gmail.com]
+  * @version 1
+  */
+ package jexer.demos;
+ import java.util.ResourceBundle;
+ import jexer.TApplication;
+ import jexer.TPanel;
+ import jexer.TText;
+ import jexer.TWindow;
+ import jexer.layout.BoxLayoutManager;
+ /**
+  * This class shows off BoxLayout and TPanel.
+  */
+ public class Demo7 {
+     /**
+      * Translated strings.
+      */
+     private static final ResourceBundle i18n = ResourceBundle.getBundle(Demo7.class.getName());
+     // ------------------------------------------------------------------------
+     // Demo7 ------------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Main entry point.
+      *
+      * @param args Command line arguments
+      */
+     public static void main(final String [] args) throws Exception {
+         // This demo will build everything "from the outside".
+         // Swing is the default backend on Windows unless explicitly
+         // overridden by jexer.Swing.
+         TApplication.BackendType backendType = TApplication.BackendType.XTERM;
+         if (System.getProperty("os.name").startsWith("Windows")) {
+             backendType = TApplication.BackendType.SWING;
+         }
+         if (System.getProperty("os.name").startsWith("Mac")) {
+             backendType = TApplication.BackendType.SWING;
+         }
+         if (System.getProperty("jexer.Swing") != null) {
+             if (System.getProperty("jexer.Swing", "false").equals("true")) {
+                 backendType = TApplication.BackendType.SWING;
+             } else {
+                 backendType = TApplication.BackendType.XTERM;
+             }
+         }
+         TApplication app = new TApplication(backendType);
+         app.addToolMenu();
+         app.addFileMenu();
+         TWindow window = new TWindow(app, i18n.getString("windowTitle"),
+             60, 22);
+         window.setLayoutManager(new BoxLayoutManager(window.getWidth() - 2,
+                 window.getHeight() - 2, false));
+         TPanel right = window.addPanel(0, 0, 10, 10);
+         TPanel left = window.addPanel(0, 0, 10, 10);
+         right.setLayoutManager(new BoxLayoutManager(right.getWidth(),
+                 right.getHeight(), true));
+         left.setLayoutManager(new BoxLayoutManager(left.getWidth(),
+                 left.getHeight(), true));
+         left.addText("C1", 0, 0, left.getWidth(), left.getHeight());
+         left.addText("C2", 0, 0, left.getWidth(), left.getHeight());
+         left.addText("C3", 0, 0, left.getWidth(), left.getHeight());
+         right.addText("C4", 0, 0, right.getWidth(), right.getHeight());
+         right.addText("C5", 0, 0, right.getWidth(), right.getHeight());
+         right.addText("C6", 0, 0, right.getWidth(), right.getHeight());
+         app.run();
+     }
+ }
index 0000000000000000000000000000000000000000,e6fd7eeef353a98e4616a3e1f591b09d0420d419..e6fd7eeef353a98e4616a3e1f591b09d0420d419
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,1 +1,1 @@@
+ windowTitle=BoxLayoutManager Demo
index 0000000000000000000000000000000000000000,3e4cbe92d56bcde373a7c6ec1d05e2327e86c0b1..3e4cbe92d56bcde373a7c6ec1d05e2327e86c0b1
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,247 +1,247 @@@
+ /*
+  * Jexer - Java Text User Interface
+  *
+  * The MIT License (MIT)
+  *
+  * 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"),
+  * to deal in the Software without restriction, including without limitation
+  * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+  * and/or sell copies of the Software, and to permit persons to whom the
+  * Software is furnished to do so, subject to the following conditions:
+  *
+  * The above copyright notice and this permission notice shall be included in
+  * all copies or substantial portions of the Software.
+  *
+  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+  * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+  * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+  * DEALINGS IN THE SOFTWARE.
+  *
+  * @author Kevin Lamonte [kevin.lamonte@gmail.com]
+  * @version 1
+  */
+ package jexer.demos;
+ import java.io.File;
+ import java.io.InputStream;
+ import java.io.IOException;
+ import java.io.OutputStream;
+ import java.io.PrintWriter;
+ import java.io.Reader;
+ import java.io.UnsupportedEncodingException;
+ import java.util.ResourceBundle;
+ import jexer.TApplication;
+ import jexer.TEditColorThemeWindow;
+ import jexer.TEditorWindow;
+ import jexer.event.TMenuEvent;
+ import jexer.menu.TMenu;
+ import jexer.menu.TMenuItem;
+ import jexer.menu.TSubMenu;
+ import jexer.backend.Backend;
+ import jexer.backend.SwingTerminal;
+ /**
+  * The demo application itself.
+  */
+ public class DemoApplication extends TApplication {
+     /**
+      * Translated strings.
+      */
+     private static final ResourceBundle i18n = ResourceBundle.getBundle(DemoApplication.class.getName());
+     // ------------------------------------------------------------------------
+     // Constructors -----------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Public constructor.
+      *
+      * @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.
+      * @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 DemoApplication(final InputStream input,
+         final OutputStream output) throws UnsupportedEncodingException {
+         super(input, output);
+         addAllWidgets();
+         getBackend().setTitle(i18n.getString("applicationTitle"));
+     }
+     /**
+      * Public constructor.
+      *
+      * @param input the InputStream underlying 'reader'.  Its available()
+      * method is used to determine if reader.read() will block or not.
+      * @param reader a Reader connected to the remote user.
+      * @param writer a PrintWriter connected to the remote user.
+      * @param setRawMode if true, set System.in into raw mode with stty.
+      * This should in general not be used.  It is here solely for Demo3,
+      * which uses System.in.
+      * @throws IllegalArgumentException if input, reader, or writer are null.
+      */
+     public DemoApplication(final InputStream input, final Reader reader,
+         final PrintWriter writer, final boolean setRawMode) {
+         super(input, reader, writer, setRawMode);
+         addAllWidgets();
+         getBackend().setTitle(i18n.getString("applicationTitle"));
+     }
+     /**
+      * Public constructor.
+      *
+      * @param input the InputStream underlying 'reader'.  Its available()
+      * method is used to determine if reader.read() will block or not.
+      * @param reader a Reader connected to the remote user.
+      * @param writer a PrintWriter connected to the remote user.
+      * @throws IllegalArgumentException if input, reader, or writer are null.
+      */
+     public DemoApplication(final InputStream input, final Reader reader,
+         final PrintWriter writer) {
+         this(input, reader, writer, false);
+     }
+     /**
+      * Public constructor.
+      *
+      * @param backend a Backend that is already ready to go.
+      */
+     public DemoApplication(final Backend backend) {
+         super(backend);
+         addAllWidgets();
+     }
+     /**
+      * Public constructor.
+      *
+      * @param backendType one of the TApplication.BackendType values
+      * @throws Exception if TApplication can't instantiate the Backend.
+      */
+     public DemoApplication(final BackendType backendType) throws Exception {
+         // For the Swing demo, use an initial size of 82x28 so that a
+         // terminal window precisely fits the window.
+         super(backendType, (backendType == BackendType.SWING ? 82 : -1),
+             (backendType == BackendType.SWING ? 28 : -1), 20);
+         addAllWidgets();
+         getBackend().setTitle(i18n.getString("applicationTitle"));
+     }
+     // ------------------------------------------------------------------------
+     // TApplication -----------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Handle menu events.
+      *
+      * @param menu menu event
+      * @return if true, the event was processed and should not be passed onto
+      * a window
+      */
+     @Override
+     public boolean onMenu(final TMenuEvent menu) {
+         if (menu.getId() == 3000) {
+             // Bigger +2
+             assert (getScreen() instanceof SwingTerminal);
+             SwingTerminal terminal = (SwingTerminal) getScreen();
+             terminal.setFontSize(terminal.getFontSize() + 2);
+             return true;
+         }
+         if (menu.getId() == 3001) {
+             // Smaller -2
+             assert (getScreen() instanceof SwingTerminal);
+             SwingTerminal terminal = (SwingTerminal) getScreen();
+             terminal.setFontSize(terminal.getFontSize() - 2);
+             return true;
+         }
+         if (menu.getId() == 2050) {
+             new TEditColorThemeWindow(this);
+             return true;
+         }
+         if (menu.getId() == TMenu.MID_OPEN_FILE) {
+             try {
+                 String filename = fileOpenBox(".");
+                  if (filename != null) {
+                      try {
+                          new TEditorWindow(this, new File(filename));
+                      } catch (IOException e) {
+                          e.printStackTrace();
+                      }
+                  }
+             } catch (IOException e) {
+                 e.printStackTrace();
+             }
+             return true;
+         }
+         return super.onMenu(menu);
+     }
+     // ------------------------------------------------------------------------
+     // DemoApplication --------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Add all the widgets of the demo.
+      */
+     private void addAllWidgets() {
+         new DemoMainWindow(this);
+         // Add the menus
+         addToolMenu();
+         addFileMenu();
+         addEditMenu();
+         TMenu demoMenu = addMenu(i18n.getString("demo"));
+         TMenuItem item = demoMenu.addItem(2000, i18n.getString("checkable"));
+         item.setCheckable(true);
+         item = demoMenu.addItem(2001, i18n.getString("disabled"));
+         item.setEnabled(false);
+         item = demoMenu.addItem(2002, i18n.getString("normal"));
+         TSubMenu subMenu = demoMenu.addSubMenu(i18n.getString("subMenu"));
+         item = demoMenu.addItem(2010, i18n.getString("normal"));
+         item = demoMenu.addItem(2050, i18n.getString("colors"));
+         item = subMenu.addItem(2000, i18n.getString("checkableSub"));
+         item.setCheckable(true);
+         item = subMenu.addItem(2001, i18n.getString("disabledSub"));
+         item.setEnabled(false);
+         item = subMenu.addItem(2002, i18n.getString("normalSub"));
+         subMenu = subMenu.addSubMenu(i18n.getString("subMenu"));
+         item = subMenu.addItem(2000, i18n.getString("checkableSub"));
+         item.setCheckable(true);
+         item = subMenu.addItem(2001, i18n.getString("disabledSub"));
+         item.setEnabled(false);
+         item = subMenu.addItem(2002, i18n.getString("normalSub"));
+         if (getScreen() instanceof SwingTerminal) {
+             TMenu swingMenu = addMenu(i18n.getString("swing"));
+             item = swingMenu.addItem(3000, i18n.getString("bigger"));
+             item = swingMenu.addItem(3001, i18n.getString("smaller"));
+         }
+         addTableMenu();
+         addWindowMenu();
+         addHelpMenu();
+     }
+ }
index 0000000000000000000000000000000000000000,95d8603e42e2b764760e56db3bc7ab3fd71ee404..95d8603e42e2b764760e56db3bc7ab3fd71ee404
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,15 +1,15 @@@
+ applicationTitle=Demo Application
+ demo=&Demo
+ checkable=&Checkable
+ disabled=Disabled
+ normal=&Normal
+ subMenu=Sub-&Menu
+ normal=N&ormal A&&D
+ colors=Co&lors...
+ checkableSub=&Checkable (sub)
+ disabledSub=Disabled (sub)
+ normalSub=&Normal (sub)
+ swing=Swin&g
+ bigger=&Bigger +2
+ smaller=&Smaller -2
index 0000000000000000000000000000000000000000,fda7bd7a15ae6567eee7a21d17b090cc46648c4f..fda7bd7a15ae6567eee7a21d17b090cc46648c4f
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,154 +1,154 @@@
+ /*
+  * Jexer - Java Text User Interface
+  *
+  * The MIT License (MIT)
+  *
+  * 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"),
+  * to deal in the Software without restriction, including without limitation
+  * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+  * and/or sell copies of the Software, and to permit persons to whom the
+  * Software is furnished to do so, subject to the following conditions:
+  *
+  * The above copyright notice and this permission notice shall be included in
+  * all copies or substantial portions of the Software.
+  *
+  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+  * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+  * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+  * DEALINGS IN THE SOFTWARE.
+  *
+  * @author Kevin Lamonte [kevin.lamonte@gmail.com]
+  * @version 1
+  */
+ package jexer.demos;
+ import java.text.MessageFormat;
+ import java.util.ArrayList;
+ import java.util.List;
+ import java.util.ResourceBundle;
+ import jexer.TAction;
+ import jexer.TApplication;
+ import jexer.TComboBox;
+ import jexer.TMessageBox;
+ import jexer.TRadioGroup;
+ import jexer.TWindow;
+ import jexer.layout.StretchLayoutManager;
+ import static jexer.TCommand.*;
+ import static jexer.TKeypress.*;
+ /**
+  * This window demonstates the TRadioGroup, TRadioButton, and TCheckBox
+  * widgets.
+  */
+ public class DemoCheckBoxWindow extends TWindow {
+     /**
+      * Translated strings.
+      */
+     private static final ResourceBundle i18n = ResourceBundle.getBundle(DemoCheckBoxWindow.class.getName());
+     // ------------------------------------------------------------------------
+     // Variables --------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Combo box.  Has to be at class scope so that it can be accessed by the
+      * anonymous TAction class.
+      */
+     TComboBox comboBox = null;
+     // ------------------------------------------------------------------------
+     // Constructors -----------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Constructor.
+      *
+      * @param parent the main application
+      */
+     DemoCheckBoxWindow(final TApplication parent) {
+         this(parent, CENTERED | RESIZABLE);
+     }
+     /**
+      * Constructor.
+      *
+      * @param parent the main application
+      * @param flags bitmask of MODAL, CENTERED, or RESIZABLE
+      */
+     DemoCheckBoxWindow(final TApplication parent, final int flags) {
+         // Construct a demo window.  X and Y don't matter because it will be
+         // centered on screen.
+         super(parent, i18n.getString("windowTitle"), 0, 0, 60, 17, flags);
+         setLayoutManager(new StretchLayoutManager(getWidth() - 2,
+                 getHeight() - 2));
+         int row = 1;
+         // Add some widgets
+         addLabel(i18n.getString("checkBoxLabel1"), 1, row);
+         addCheckBox(35, row++, i18n.getString("checkBoxText1"), false);
+         addLabel(i18n.getString("checkBoxLabel2"), 1, row);
+         addCheckBox(35, row++, i18n.getString("checkBoxText2"), true);
+         row += 2;
+         TRadioGroup group = addRadioGroup(1, row,
+             i18n.getString("radioGroupTitle"));
+         group.addRadioButton(i18n.getString("radioOption1"));
+         group.addRadioButton(i18n.getString("radioOption2"));
+         group.addRadioButton(i18n.getString("radioOption3"));
+         List<String> comboValues = new ArrayList<String>();
+         comboValues.add(i18n.getString("comboBoxString0"));
+         comboValues.add(i18n.getString("comboBoxString1"));
+         comboValues.add(i18n.getString("comboBoxString2"));
+         comboValues.add(i18n.getString("comboBoxString3"));
+         comboValues.add(i18n.getString("comboBoxString4"));
+         comboValues.add(i18n.getString("comboBoxString5"));
+         comboValues.add(i18n.getString("comboBoxString6"));
+         comboValues.add(i18n.getString("comboBoxString7"));
+         comboValues.add(i18n.getString("comboBoxString8"));
+         comboValues.add(i18n.getString("comboBoxString9"));
+         comboValues.add(i18n.getString("comboBoxString10"));
+         comboBox = addComboBox(35, row, 12, comboValues, 2, 6,
+             new TAction() {
+                 public void DO() {
+                     getApplication().messageBox(i18n.getString("messageBoxTitle"),
+                         MessageFormat.format(i18n.getString("messageBoxPrompt"),
+                             comboBox.getText()),
+                         TMessageBox.Type.OK);
+                 }
+             }
+         );
+         addButton(i18n.getString("closeWindow"),
+             (getWidth() - 14) / 2, getHeight() - 4,
+             new TAction() {
+                 public void DO() {
+                     DemoCheckBoxWindow.this.getApplication()
+                         .closeWindow(DemoCheckBoxWindow.this);
+                 }
+             }
+         );
+         statusBar = newStatusBar(i18n.getString("statusBar"));
+         statusBar.addShortcutKeypress(kbF1, cmHelp,
+             i18n.getString("statusBarHelp"));
+         statusBar.addShortcutKeypress(kbF2, cmShell,
+             i18n.getString("statusBarShell"));
+         statusBar.addShortcutKeypress(kbF3, cmOpen,
+             i18n.getString("statusBarOpen"));
+         statusBar.addShortcutKeypress(kbF10, cmExit,
+             i18n.getString("statusBarExit"));
+     }
+ }
index 0000000000000000000000000000000000000000,61210ce92f53ec83f6696bf226a8ba7d370cb342..61210ce92f53ec83f6696bf226a8ba7d370cb342
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,30 +1,30 @@@
+ windowTitle=Radiobuttons, CheckBoxes, and ComboBox
+ statusBar=Radiobuttons and checkboxes
+ statusBarHelp=Help
+ statusBarShell=Shell
+ statusBarOpen=Open
+ statusBarExit=Exit
+ checkBoxLabel1=Check box example 1
+ checkBoxText1=CheckBox 1
+ checkBoxLabel2=Check box example 2
+ checkBoxText2=CheckBox 2
+ radioGroupTitle=Group 1
+ radioOption1=Radio option 1
+ radioOption2=Radio option 2
+ radioOption3=Radio option 3
+ comboBoxString0=String 0
+ comboBoxString1=String 1
+ comboBoxString2=String 2
+ comboBoxString3=String 3
+ comboBoxString4=String 4
+ comboBoxString5=String 5
+ comboBoxString6=String 6
+ comboBoxString7=String 7
+ comboBoxString8=String 8
+ comboBoxString9=String 9
+ comboBoxString10=String 10
+ messageBoxTitle=ComboBox
+ messageBoxPrompt=You selected the following value:\n\n{0}\n
+ closeWindow=&Close Window
index 0000000000000000000000000000000000000000,87798fb3f4b08d5ca6f684dc44928143f57aa35b..87798fb3f4b08d5ca6f684dc44928143f57aa35b
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,148 +1,148 @@@
+ /*
+  * Jexer - Java Text User Interface
+  *
+  * The MIT License (MIT)
+  *
+  * 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"),
+  * to deal in the Software without restriction, including without limitation
+  * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+  * and/or sell copies of the Software, and to permit persons to whom the
+  * Software is furnished to do so, subject to the following conditions:
+  *
+  * The above copyright notice and this permission notice shall be included in
+  * all copies or substantial portions of the Software.
+  *
+  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+  * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+  * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+  * DEALINGS IN THE SOFTWARE.
+  *
+  * @author Kevin Lamonte [kevin.lamonte@gmail.com]
+  * @version 1
+  */
+ package jexer.demos;
+ import java.util.ResourceBundle;
+ import jexer.TApplication;
+ import jexer.TEditorWidget;
+ import jexer.TWidget;
+ import jexer.TWindow;
+ import jexer.event.TResizeEvent;
+ import static jexer.TCommand.*;
+ import static jexer.TKeypress.*;
+ /**
+  * This window demonstates the TEditor widget.
+  */
+ public class DemoEditorWindow extends TWindow {
+     /**
+      * Translated strings.
+      */
+     private static final ResourceBundle i18n = ResourceBundle.getBundle(DemoEditorWindow.class.getName());
+     // ------------------------------------------------------------------------
+     // Variables --------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Hang onto my TEditor so I can resize it with the window.
+      */
+     private TEditorWidget editField;
+     // ------------------------------------------------------------------------
+     // Constructors -----------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Public constructor makes a text window out of any string.
+      *
+      * @param parent the main application
+      * @param title the text string
+      * @param text the text string
+      */
+     public DemoEditorWindow(final TApplication parent, final String title,
+         final String text) {
+         super(parent, title, 0, 0, 44, 22, RESIZABLE);
+         editField = addEditor(text, 0, 0, 42, 20);
+         statusBar = newStatusBar(i18n.getString("statusBar"));
+         statusBar.addShortcutKeypress(kbF1, cmHelp,
+             i18n.getString("statusBarHelp"));
+         statusBar.addShortcutKeypress(kbF2, cmShell,
+             i18n.getString("statusBarShell"));
+         statusBar.addShortcutKeypress(kbF10, cmExit,
+             i18n.getString("statusBarExit"));
+     }
+     /**
+      * Public constructor.
+      *
+      * @param parent the main application
+      */
+     public DemoEditorWindow(final TApplication parent) {
+         this(parent, i18n.getString("windowTitle"),
+ "This is an example of an editable text field.  Some example text follows.\n" +
+ "\n" +
+ "This library implements a text-based windowing system loosely\n" +
+ "reminiscent of Borland's [Turbo\n" +
+ "Vision](http://en.wikipedia.org/wiki/Turbo_Vision) library.  For those\n" +
+ "wishing to use the actual C++ Turbo Vision library, see [Sergio\n" +
+ "Sigala's updated version](http://tvision.sourceforge.net/) that runs\n" +
+ "on many more platforms.\n" +
+ "\n" +
+ "This library is licensed MIT.  See the file LICENSE for the full license\n" +
+ "for the details.\n" +
+ "\n" +
+ "package jexer.demos;\n" +
+ "\n" +
+ "import jexer.*;\n" +
+ "import jexer.event.*;\n" +
+ "import static jexer.TCommand.*;\n" +
+ "import static jexer.TKeypress.*;\n" +
+ "\n" +
+ "/**\n" +
+ " * This window demonstates the TText, THScroller, and TVScroller widgets.\n" +
+ " */\n" +
+ "public class DemoEditorWindow extends TWindow {\n" +
+ "\n" +
+ "1 2 3 123\n" +
+ "\n"
+         );
+     }
+     // ------------------------------------------------------------------------
+     // TWindow ----------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Handle window/screen resize events.
+      *
+      * @param event resize event
+      */
+     @Override
+     public void onResize(final TResizeEvent event) {
+         if (event.getType() == TResizeEvent.Type.WIDGET) {
+             // Resize the text field
+             TResizeEvent editSize = new TResizeEvent(TResizeEvent.Type.WIDGET,
+                 event.getWidth() - 2, event.getHeight() - 2);
+             editField.onResize(editSize);
+             return;
+         }
+         // Pass to children instead
+         for (TWidget widget: getChildren()) {
+             widget.onResize(event);
+         }
+     }
+ }
index 0000000000000000000000000000000000000000,3fa321280266942e2ae3396861a057053acaa47a..3fa321280266942e2ae3396861a057053acaa47a
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,6 +1,6 @@@
+ windowTitle=Editor
+ statusBar=Editable text demo window
+ statusBarHelp=Help
+ statusBarShell=Shell
+ statusBarExit=Exit
index 0000000000000000000000000000000000000000,8f77448b29f45c65cd67e52c38268e0564ad1e0d..8f77448b29f45c65cd67e52c38268e0564ad1e0d
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,370 +1,370 @@@
+ /*
+  * Jexer - Java Text User Interface
+  *
+  * The MIT License (MIT)
+  *
+  * 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"),
+  * to deal in the Software without restriction, including without limitation
+  * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+  * and/or sell copies of the Software, and to permit persons to whom the
+  * Software is furnished to do so, subject to the following conditions:
+  *
+  * The above copyright notice and this permission notice shall be included in
+  * all copies or substantial portions of the Software.
+  *
+  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+  * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+  * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+  * DEALINGS IN THE SOFTWARE.
+  *
+  * @author Kevin Lamonte [kevin.lamonte@gmail.com]
+  * @version 1
+  */
+ package jexer.demos;
+ import java.io.File;
+ import java.io.IOException;
+ import java.text.MessageFormat;
+ import java.util.ResourceBundle;
+ import jexer.TAction;
+ import jexer.TApplication;
+ import jexer.TEditColorThemeWindow;
+ import jexer.TEditorWindow;
+ import jexer.TLabel;
+ import jexer.TProgressBar;
+ import jexer.TTableWindow;
+ import jexer.TTimer;
+ import jexer.TWidget;
+ import jexer.TWindow;
+ import jexer.event.TCommandEvent;
+ import jexer.layout.StretchLayoutManager;
+ import static jexer.TCommand.*;
+ import static jexer.TKeypress.*;
+ /**
+  * This is the main "demo" application window.  It makes use of the TTimer,
+  * TProgressBox, TLabel, TButton, and TField widgets.
+  */
+ public class DemoMainWindow extends TWindow {
+     /**
+      * Translated strings.
+      */
+     private static final ResourceBundle i18n = ResourceBundle.getBundle(DemoMainWindow.class.getName());
+     // ------------------------------------------------------------------------
+     // Variables --------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Timer that increments a number.
+      */
+     private TTimer timer1;
+     /**
+      * Timer that increments a number.
+      */
+     private TTimer timer2;
+     /**
+      * Timer label is updated with timer ticks.
+      */
+     TLabel timerLabel;
+     /**
+      * Timer increment used by the timer loop.  Has to be at class scope so
+      * that it can be accessed by the anonymous TAction class.
+      */
+     int timer1I = 0;
+     /**
+      * Timer increment used by the timer loop.  Has to be at class scope so
+      * that it can be accessed by the anonymous TAction class.
+      */
+     int timer2I = 0;
+     /**
+      * Progress bar used by the timer loop.  Has to be at class scope so that
+      * it can be accessed by the anonymous TAction class.
+      */
+     TProgressBar progressBar1;
+     /**
+      * Progress bar used by the timer loop.  Has to be at class scope so that
+      * it can be accessed by the anonymous TAction class.
+      */
+     TProgressBar progressBar2;
+     // ------------------------------------------------------------------------
+     // Constructors -----------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Construct demo window.  It will be centered on screen.
+      *
+      * @param parent the main application
+      */
+     public DemoMainWindow(final TApplication parent) {
+         this(parent, CENTERED | RESIZABLE);
+     }
+     /**
+      * Constructor.
+      *
+      * @param parent the main application
+      * @param flags bitmask of MODAL, CENTERED, or RESIZABLE
+      */
+     private DemoMainWindow(final TApplication parent, final int flags) {
+         // Construct a demo window.  X and Y don't matter because it will be
+         // centered on screen.
+         super(parent, i18n.getString("windowTitle"), 0, 0, 64, 23, flags);
+         setLayoutManager(new StretchLayoutManager(getWidth() - 2,
+                 getHeight() - 2));
+         int row = 1;
+         // Add some widgets
+         addLabel(i18n.getString("messageBoxLabel"), 1, row);
+         TWidget first = addButton(i18n.getString("messageBoxButton"), 35, row,
+             new TAction() {
+                 public void DO() {
+                     new DemoMsgBoxWindow(getApplication());
+                 }
+             }
+         );
+         row += 2;
+         addLabel(i18n.getString("openModalLabel"), 1, row);
+         addButton(i18n.getString("openModalButton"), 35, row,
+             new TAction() {
+                 public void DO() {
+                     new DemoMainWindow(getApplication(), MODAL);
+                 }
+             }
+         );
+         row += 2;
+         addLabel(i18n.getString("textFieldLabel"), 1, row);
+         addButton(i18n.getString("textFieldButton"), 35, row,
+             new TAction() {
+                 public void DO() {
+                     new DemoTextFieldWindow(getApplication());
+                 }
+             }
+         );
+         row += 2;
+         addLabel(i18n.getString("radioButtonLabel"), 1, row);
+         addButton(i18n.getString("radioButtonButton"), 35, row,
+             new TAction() {
+                 public void DO() {
+                     new DemoCheckBoxWindow(getApplication());
+                 }
+             }
+         );
+         row += 2;
+         addLabel(i18n.getString("editorLabel"), 1, row);
+         addButton(i18n.getString("editorButton1"), 35, row,
+             new TAction() {
+                 public void DO() {
+                     new DemoEditorWindow(getApplication());
+                 }
+             }
+         );
+         addButton(i18n.getString("editorButton2"), 48, row,
+             new TAction() {
+                 public void DO() {
+                     new TEditorWindow(getApplication());
+                 }
+             }
+         );
+         row += 2;
+         addLabel(i18n.getString("textAreaLabel"), 1, row);
+         addButton(i18n.getString("textAreaButton"), 35, row,
+             new TAction() {
+                 public void DO() {
+                     new DemoTextWindow(getApplication());
+                 }
+             }
+         );
+         row += 2;
+         addLabel(i18n.getString("ttableLabel"), 1, row);
+         addButton(i18n.getString("ttableButton1"), 35, row,
+             new TAction() {
+                 public void DO() {
+                     new DemoTableWindow(getApplication(),
+                         i18n.getString("tableWidgetDemo"));
+                 }
+             }
+         );
+         addButton(i18n.getString("ttableButton2"), 48, row,
+             new TAction() {
+                 public void DO() {
+                     new TTableWindow(getApplication(),
+                         i18n.getString("tableDemo"));
+                 }
+             }
+         );
+         row += 2;
+         addLabel(i18n.getString("treeViewLabel"), 1, row);
+         addButton(i18n.getString("treeViewButton"), 35, row,
+             new TAction() {
+                 public void DO() {
+                     try {
+                         new DemoTreeViewWindow(getApplication());
+                     } catch (Exception e) {
+                         e.printStackTrace();
+                     }
+                 }
+             }
+         );
+         row += 2;
+         addLabel(i18n.getString("terminalLabel"), 1, row);
+         addButton(i18n.getString("terminalButton"), 35, row,
+             new TAction() {
+                 public void DO() {
+                     getApplication().openTerminal(0, 0);
+                 }
+             }
+         );
+         row += 2;
+         addLabel(i18n.getString("colorEditorLabel"), 1, row);
+         addButton(i18n.getString("colorEditorButton"), 35, row,
+             new TAction() {
+                 public void DO() {
+                     new TEditColorThemeWindow(getApplication());
+                 }
+             }
+         );
+         row = 15;
+         progressBar1 = addProgressBar(48, row, 12, 0);
+         row++;
+         timerLabel = addLabel(i18n.getString("timerLabel"), 48, row);
+         timer1 = getApplication().addTimer(250, true,
+             new TAction() {
+                 public void DO() {
+                     timerLabel.setLabel(String.format(i18n.
+                             getString("timerText"), timer1I));
+                     timerLabel.setWidth(timerLabel.getLabel().length());
+                     if (timer1I < 100) {
+                         timer1I++;
+                     } else {
+                         timer1.setRecurring(false);
+                     }
+                     progressBar1.setValue(timer1I);
+                 }
+             }
+         );
+         row += 2;
+         progressBar2 = addProgressBar(48, row, 12, 0);
+         progressBar2.setLeftBorderChar('\u255e');
+         progressBar2.setRightBorderChar('\u2561');
+         progressBar2.setCompletedChar('\u2592');
+         progressBar2.setRemainingChar('\u2550');
+         row++;
+         timer2 = getApplication().addTimer(125, true,
+             new TAction() {
+                 public void DO() {
+                     if (timer2I < 100) {
+                         timer2I++;
+                     } else {
+                         timer2.setRecurring(false);
+                     }
+                     progressBar2.setValue(timer2I);
+                 }
+             }
+         );
+         /*
+         addButton("Exception", 35, row + 3,
+             new TAction() {
+                 public void DO() {
+                     try {
+                         throw new RuntimeException("FUBAR'd!");
+                     } catch (Exception e) {
+                         new jexer.TExceptionDialog(getApplication(), e);
+                     }
+                 }
+             }
+         );
+          */
+         activate(first);
+         statusBar = newStatusBar(i18n.getString("statusBar"));
+         statusBar.addShortcutKeypress(kbF1, cmHelp,
+             i18n.getString("statusBarHelp"));
+         statusBar.addShortcutKeypress(kbF2, cmShell,
+             i18n.getString("statusBarShell"));
+         statusBar.addShortcutKeypress(kbF3, cmOpen,
+             i18n.getString("statusBarOpen"));
+         statusBar.addShortcutKeypress(kbF10, cmExit,
+             i18n.getString("statusBarExit"));
+     }
+     // ------------------------------------------------------------------------
+     // TWindow ----------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * We need to override onClose so that the timer will no longer be called
+      * after we close the window.  TTimers currently are completely unaware
+      * of the rest of the UI classes.
+      */
+     @Override
+     public void onClose() {
+         getApplication().removeTimer(timer1);
+         getApplication().removeTimer(timer2);
+     }
+     /**
+      * Method that subclasses can override to handle posted command events.
+      *
+      * @param command command event
+      */
+     @Override
+     public void onCommand(final TCommandEvent command) {
+         if (command.equals(cmOpen)) {
+             try {
+                 String filename = fileOpenBox(".");
+                 if (filename != null) {
+                     try {
+                         new TEditorWindow(getApplication(),
+                             new File(filename));
+                     } catch (IOException e) {
+                         messageBox(i18n.getString("errorTitle"),
+                             MessageFormat.format(i18n.
+                                 getString("errorReadingFile"), e.getMessage()));
+                     }
+                 }
+             } catch (IOException e) {
+                         messageBox(i18n.getString("errorTitle"),
+                             MessageFormat.format(i18n.
+                                 getString("errorOpeningFile"), e.getMessage()));
+             }
+             return;
+         }
+         // Didn't handle it, let children get it instead
+         super.onCommand(command);
+     }
+ }
index 0000000000000000000000000000000000000000,dba1cb04c0972891844dc5bf82ad5d91c67f8b61..dba1cb04c0972891844dc5bf82ad5d91c67f8b61
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,39 +1,39 @@@
+ windowTitle=Demo Window
+ statusBar=Demo Main Window
+ statusBarHelp=Help
+ statusBarShell=Shell
+ statusBarOpen=Open
+ statusBarExit=Exit
+ messageBoxLabel=Message Boxes
+ messageBoxButton=&MessageBoxes
+ openModalLabel=Open me as modal
+ openModalButton=M&odal
+ textFieldLabel=Text fields, calendar, spinner
+ textFieldButton=Field&s
+ radioButtonLabel=Radio buttons, checkbox, combobox
+ radioButtonButton=&CheckBoxes
+ editorLabel=Editor window
+ editorButton1=&1 Widget
+ editorButton2=&2 Window
+ ttableLabel=Editable Table
+ ttableButton1=&4 Widget
+ ttableButton2=&5 Window
+ textAreaLabel=Text areas
+ textAreaButton=&3 Text
+ treeViewLabel=Tree views
+ treeViewButton=Tree&View
+ terminalLabel=Terminal
+ terminalButton=Termi&nal
+ colorEditorLabel=Color editor
+ colorEditorButton=Co&lors
+ timerLabel=Timer
+ timerText=Timer: %d
+ errorTitle=Error
+ errorReadingFile=Error reading file: {0}
+ errorOpeningFile=Error opening file dialog: {0}
+ tableWidgetDemo=TTableWidget Demo
+ tableDemo=TTableWindow Demo
index 0000000000000000000000000000000000000000,0485f5184195a1a836f5842d1cda069ebe937dd6..0485f5184195a1a836f5842d1cda069ebe937dd6
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,190 +1,190 @@@
+ /*
+  * Jexer - Java Text User Interface
+  *
+  * The MIT License (MIT)
+  *
+  * 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"),
+  * to deal in the Software without restriction, including without limitation
+  * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+  * and/or sell copies of the Software, and to permit persons to whom the
+  * Software is furnished to do so, subject to the following conditions:
+  *
+  * The above copyright notice and this permission notice shall be included in
+  * all copies or substantial portions of the Software.
+  *
+  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+  * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+  * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+  * DEALINGS IN THE SOFTWARE.
+  *
+  * @author Kevin Lamonte [kevin.lamonte@gmail.com]
+  * @version 1
+  */
+ package jexer.demos;
+ import java.text.MessageFormat;
+ import java.util.ResourceBundle;
+ import jexer.TAction;
+ import jexer.TApplication;
+ import jexer.TInputBox;
+ import jexer.TMessageBox;
+ import jexer.TWindow;
+ import jexer.layout.StretchLayoutManager;
+ import static jexer.TCommand.*;
+ import static jexer.TKeypress.*;
+ /**
+  * This window demonstates the TMessageBox and TInputBox widgets.
+  */
+ public class DemoMsgBoxWindow extends TWindow {
+     /**
+      * Translated strings.
+      */
+     private static final ResourceBundle i18n = ResourceBundle.getBundle(DemoMsgBoxWindow.class.getName());
+     // ------------------------------------------------------------------------
+     // Constructors -----------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Constructor.
+      *
+      * @param parent the main application
+      */
+     DemoMsgBoxWindow(final TApplication parent) {
+         this(parent, TWindow.CENTERED | TWindow.RESIZABLE);
+     }
+     /**
+      * Constructor.
+      *
+      * @param parent the main application
+      * @param flags bitmask of MODAL, CENTERED, or RESIZABLE
+      */
+     DemoMsgBoxWindow(final TApplication parent, final int flags) {
+         // Construct a demo window.  X and Y don't matter because it
+         // will be centered on screen.
+         super(parent, i18n.getString("windowTitle"), 0, 0, 64, 18, flags);
+         setLayoutManager(new StretchLayoutManager(getWidth() - 2,
+                 getHeight() - 2));
+         int row = 1;
+         // Add some widgets
+         addLabel(i18n.getString("messageBoxLabel1"), 1, row);
+         addButton(i18n.getString("messageBoxButton1"), 35, row,
+             new TAction() {
+                 public void DO() {
+                     getApplication().messageBox(i18n.
+                         getString("messageBoxTitle1"),
+                         i18n.getString("messageBoxPrompt1"),
+                         TMessageBox.Type.OK);
+                 }
+             }
+         );
+         row += 2;
+         addLabel(i18n.getString("messageBoxLabel2"), 1, row);
+         addButton(i18n.getString("messageBoxButton2"), 35, row,
+             new TAction() {
+                 public void DO() {
+                     getApplication().messageBox(i18n.
+                         getString("messageBoxTitle2"),
+                         i18n.getString("messageBoxPrompt2"),
+                         TMessageBox.Type.OKCANCEL);
+                 }
+             }
+         );
+         row += 2;
+         addLabel(i18n.getString("messageBoxLabel3"), 1, row);
+         addButton(i18n.getString("messageBoxButton3"), 35, row,
+             new TAction() {
+                 public void DO() {
+                     getApplication().messageBox(i18n.
+                         getString("messageBoxTitle3"),
+                         i18n.getString("messageBoxPrompt3"),
+                         TMessageBox.Type.YESNO);
+                 }
+             }
+         );
+         row += 2;
+         addLabel(i18n.getString("messageBoxLabel4"), 1, row);
+         addButton(i18n.getString("messageBoxButton4"), 35, row,
+             new TAction() {
+                 public void DO() {
+                     getApplication().messageBox(i18n.
+                         getString("messageBoxTitle4"),
+                         i18n.getString("messageBoxPrompt4"),
+                         TMessageBox.Type.YESNOCANCEL);
+                 }
+             }
+         );
+         row += 2;
+         addLabel(i18n.getString("inputBoxLabel1"), 1, row);
+         addButton(i18n.getString("inputBoxButton1"), 35, row,
+             new TAction() {
+                 public void DO() {
+                     TInputBox in = getApplication().inputBox(i18n.
+                         getString("inputBoxTitle1"),
+                         i18n.getString("inputBoxPrompt1"),
+                         i18n.getString("inputBoxInput1"));
+                     getApplication().messageBox(i18n.
+                         getString("inputBoxAnswerTitle1"),
+                         MessageFormat.format(i18n.
+                             getString("inputBoxAnswerPrompt1"), in.getText()));
+                 }
+             }
+         );
+         row += 2;
+         addLabel(i18n.getString("inputBoxLabel2"), 1, row);
+         addButton(i18n.getString("inputBoxButton2"), 35, row,
+             new TAction() {
+                 public void DO() {
+                     TInputBox in = getApplication().inputBox(i18n.
+                         getString("inputBoxTitle2"),
+                         i18n.getString("inputBoxPrompt2"),
+                         i18n.getString("inputBoxInput2"),
+                         TInputBox.Type.OKCANCEL);
+                     getApplication().messageBox(i18n.
+                         getString("inputBoxAnswerTitle2"),
+                         MessageFormat.format(i18n.
+                             getString("inputBoxAnswerPrompt2"), in.getText(),
+                             in.getResult()));
+                 }
+             }
+         );
+         row += 2;
+         addButton(i18n.getString("closeWindow"),
+             (getWidth() - 14) / 2, getHeight() - 4,
+             new TAction() {
+                 public void DO() {
+                     getApplication().closeWindow(DemoMsgBoxWindow.this);
+                 }
+             }
+         );
+         statusBar = newStatusBar(i18n.getString("statusBar"));
+         statusBar.addShortcutKeypress(kbF1, cmHelp,
+             i18n.getString("statusBarHelp"));
+         statusBar.addShortcutKeypress(kbF2, cmShell,
+             i18n.getString("statusBarShell"));
+         statusBar.addShortcutKeypress(kbF3, cmOpen,
+             i18n.getString("statusBarOpen"));
+         statusBar.addShortcutKeypress(kbF10, cmExit,
+             i18n.getString("statusBarExit"));
+     }
+ }
index 0000000000000000000000000000000000000000,47a858ab03f0777c172ffe401a469c76070c8a50..47a858ab03f0777c172ffe401a469c76070c8a50
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,45 +1,45 @@@
+ windowTitle=Message Boxes
+ statusBar=Message boxes
+ statusBarHelp=Help
+ statusBarShell=Shell
+ statusBarOpen=Open
+ statusBarExit=Exit
+ messageBoxLabel1=Default OK message box
+ messageBoxButton1=Open O&K MB
+ messageBoxTitle1=OK MessageBox
+ messageBoxPrompt1=This is an example of a OK MessageBox.  This is the\ndefault MessageBox.\n\nNote that the MessageBox text can span multiple\nlines.\n\nThe default result (if someone hits the top-left\nclose button) is OK.\n
+ messageBoxLabel2=OK/Cancel message box
+ messageBoxButton2=O&pen OKC MB
+ messageBoxTitle2=OK/Cancel MessageBox
+ messageBoxPrompt2=This is an example of a OK/Cancel MessageBox.\n\nNote that the MessageBox text can span multiple\nlines.\n\nThe default result (if someone hits the top-leftclose button) is CANCEL.\n
+ messageBoxLabel3=Yes/No message box
+ messageBoxButton3=Open &YN MB
+ messageBoxTitle3=Yes/No MessageBox
+ messageBoxPrompt3=This is an example of a Yes/No MessageBox.\n\nNote that the MessageBox text can span multiple\nlines.\n\nThe default result (if someone hits the top-left\nclose button) is NO.\n
+ messageBoxLabel4=Yes/No/Cancel message box
+ messageBoxButton4=Ope&n YNC MB
+ messageBoxTitle4=Yes/No/Cancel MessageBox
+ messageBoxPrompt4=This is an example of a Yes/No/Cancel MessageBox.\n\nNote that the MessageBox text can span multiple\nlines.\n\nThe default result (if someone hits the top-left\nclose button) is CANCEL.\n
+ inputBoxLabel1=Input box 1
+ inputBoxButton1=Open &input box
+ inputBoxTitle1=Input Box
+ inputBoxPrompt1=This is an example of an InputBox.\n\nNote that the InputBox text can span multiple\nlines.\n
+ inputBoxInput1=some input text
+ inputBoxAnswerTitle1=Your InputBox Answer
+ inputBoxAnswerPrompt1=You entered: {0}
+ inputBoxLabel2=Input box 2
+ inputBoxButton2=Cance&llable input box
+ inputBoxTitle2=Input Box
+ inputBoxPrompt2=This is an example of an InputBox.\n\nNote that the InputBox text can span multiple\nlines.\nThis one has both OK and Cancel buttons.\n
+ inputBoxInput2=some input text
+ inputBoxAnswerTitle2=Your InputBox Answer
+ inputBoxAnswerPrompt2=You entered: {0} and pressed {1}
+ closeWindow=&Close Window
index 0000000000000000000000000000000000000000,85da32aaf0c6deef861ff62a5c5ad6b2d5330740..85da32aaf0c6deef861ff62a5c5ad6b2d5330740
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,118 +1,118 @@@
+ /*
+  * Jexer - Java Text User Interface
+  *
+  * The MIT License (MIT)
+  *
+  * 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"),
+  * to deal in the Software without restriction, including without limitation
+  * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+  * and/or sell copies of the Software, and to permit persons to whom the
+  * Software is furnished to do so, subject to the following conditions:
+  *
+  * The above copyright notice and this permission notice shall be included in
+  * all copies or substantial portions of the Software.
+  *
+  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+  * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+  * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+  * DEALINGS IN THE SOFTWARE.
+  *
+  * @author Kevin Lamonte [kevin.lamonte@gmail.com]
+  * @version 1
+  */
+ package jexer.demos;
+ import java.util.ResourceBundle;
+ import jexer.TApplication;
+ import jexer.TTableWidget;
+ import jexer.TWidget;
+ import jexer.TWindow;
+ import jexer.event.TResizeEvent;
+ import static jexer.TCommand.*;
+ import static jexer.TKeypress.*;
+ /**
+  * This window demonstates the TTable widget.
+  */
+ public class DemoTableWindow extends TWindow {
+     /**
+      * Translated strings.
+      */
+     private static final ResourceBundle i18n = ResourceBundle.getBundle(DemoTableWindow.class.getName());
+     // ------------------------------------------------------------------------
+     // Variables --------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Hang onto my TTable so I can resize it with the window.
+      */
+     private TTableWidget tableField;
+     // ------------------------------------------------------------------------
+     // Constructors -----------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Public constructor makes a text window out of any string.
+      *
+      * @param parent the main application
+      * @param title the text string
+      */
+     public DemoTableWindow(final TApplication parent, final String title) {
+         super(parent, title, 0, 0, 44, 22, RESIZABLE);
+         tableField = new TTableWidget(this, 0, 0, 42, 20);
+         statusBar = newStatusBar(i18n.getString("statusBar"));
+         statusBar.addShortcutKeypress(kbF1, cmHelp,
+             i18n.getString("statusBarHelp"));
+         statusBar.addShortcutKeypress(kbF2, cmShell,
+             i18n.getString("statusBarShell"));
+         statusBar.addShortcutKeypress(kbF10, cmExit,
+             i18n.getString("statusBarExit"));
+     }
+     /**
+      * Public constructor.
+      *
+      * @param parent the main application
+      */
+     public DemoTableWindow(final TApplication parent) {
+         this(parent, i18n.getString("windowTitle"));
+     }
+     // ------------------------------------------------------------------------
+     // TWindow ----------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Handle window/screen resize events.
+      *
+      * @param event resize event
+      */
+     @Override
+     public void onResize(final TResizeEvent event) {
+         if (event.getType() == TResizeEvent.Type.WIDGET) {
+             // Resize the text field
+             TResizeEvent tableSize = new TResizeEvent(TResizeEvent.Type.WIDGET,
+                 event.getWidth() - 2, event.getHeight() - 2);
+             tableField.onResize(tableSize);
+             return;
+         }
+         // Pass to children instead
+         for (TWidget widget: getChildren()) {
+             widget.onResize(event);
+         }
+     }
+ }
index 0000000000000000000000000000000000000000,ecc9ec5d47d5fec11a76a83bd34abe8303415141..ecc9ec5d47d5fec11a76a83bd34abe8303415141
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,6 +1,6 @@@
+ windowTitle=Table
+ statusBar=Table datagrid demo window
+ statusBarHelp=Help
+ statusBarShell=Shell
+ statusBarExit=Exit
index 0000000000000000000000000000000000000000,2c6116a980a4c0c243599c82a99fc0abee09f4a1..2c6116a980a4c0c243599c82a99fc0abee09f4a1
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,182 +1,182 @@@
+ /*
+  * Jexer - Java Text User Interface
+  *
+  * The MIT License (MIT)
+  *
+  * 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"),
+  * to deal in the Software without restriction, including without limitation
+  * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+  * and/or sell copies of the Software, and to permit persons to whom the
+  * Software is furnished to do so, subject to the following conditions:
+  *
+  * The above copyright notice and this permission notice shall be included in
+  * all copies or substantial portions of the Software.
+  *
+  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+  * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+  * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+  * DEALINGS IN THE SOFTWARE.
+  *
+  * @author Kevin Lamonte [kevin.lamonte@gmail.com]
+  * @version 1
+  */
+ package jexer.demos;
+ import java.text.MessageFormat;
+ import java.util.Calendar;
+ import java.util.Date;
+ import java.util.GregorianCalendar;
+ import java.util.Locale;
+ import java.util.ResourceBundle;
+ import jexer.TAction;
+ import jexer.TApplication;
+ import jexer.TCalendar;
+ import jexer.TField;
+ import jexer.TLabel;
+ import jexer.TMessageBox;
+ import jexer.TWindow;
+ import jexer.layout.StretchLayoutManager;
+ import static jexer.TCommand.*;
+ import static jexer.TKeypress.*;
+ /**
+  * This window demonstates the TField and TPasswordField widgets.
+  */
+ public class DemoTextFieldWindow extends TWindow {
+     /**
+      * Translated strings.
+      */
+     private static final ResourceBundle i18n = ResourceBundle.getBundle(DemoTextFieldWindow.class.getName());
+     // ------------------------------------------------------------------------
+     // Variables --------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Calendar.  Has to be at class scope so that it can be accessed by the
+      * anonymous TAction class.
+      */
+     TCalendar calendar = null;
+     /**
+      * Day of week label is updated with TSpinner clicks.
+      */
+     TLabel dayOfWeekLabel;
+     /**
+      * Day of week to demonstrate TSpinner.  Has to be at class scope so that
+      * it can be accessed by the anonymous TAction class.
+      */
+     GregorianCalendar dayOfWeekCalendar = new GregorianCalendar();
+     // ------------------------------------------------------------------------
+     // Constructors -----------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Constructor.
+      *
+      * @param parent the main application
+      */
+     DemoTextFieldWindow(final TApplication parent) {
+         this(parent, TWindow.CENTERED | TWindow.RESIZABLE);
+     }
+     /**
+      * Constructor.
+      *
+      * @param parent the main application
+      * @param flags bitmask of MODAL, CENTERED, or RESIZABLE
+      */
+     DemoTextFieldWindow(final TApplication parent, final int flags) {
+         // Construct a demo window.  X and Y don't matter because it
+         // will be centered on screen.
+         super(parent, i18n.getString("windowTitle"), 0, 0, 60, 20, flags);
+         setLayoutManager(new StretchLayoutManager(getWidth() - 2,
+                 getHeight() - 2));
+         int row = 1;
+         addLabel(i18n.getString("textField1"), 1, row);
+         addField(35, row++, 15, false, "Field text");
+         addLabel(i18n.getString("textField2"), 1, row);
+         addField(35, row++, 15, true);
+         addLabel(i18n.getString("textField3"), 1, row);
+         addPasswordField(35, row++, 15, false);
+         addLabel(i18n.getString("textField4"), 1, row);
+         addPasswordField(35, row++, 15, true, "hunter2");
+         addLabel(i18n.getString("textField5"), 1, row);
+         TField selected = addField(35, row++, 40, false,
+             i18n.getString("textField6"));
+         row += 1;
+         calendar = addCalendar(1, row++,
+             new TAction() {
+                 public void DO() {
+                     getApplication().messageBox(i18n.getString("calendarTitle"),
+                         MessageFormat.format(i18n.getString("calendarMessage"),
+                             new Date(calendar.getValue().getTimeInMillis())),
+                         TMessageBox.Type.OK);
+                 }
+             }
+         );
+         dayOfWeekLabel = addLabel("Wednesday-", 35, row - 1, "tmenu", false);
+         dayOfWeekLabel.setLabel(String.format("%-10s",
+                 dayOfWeekCalendar.getDisplayName(Calendar.DAY_OF_WEEK,
+                     Calendar.LONG, Locale.getDefault())));
+         addSpinner(35 + dayOfWeekLabel.getWidth(), row - 1,
+             new TAction() {
+                 public void DO() {
+                     dayOfWeekCalendar.add(Calendar.DAY_OF_WEEK, 1);
+                     dayOfWeekLabel.setLabel(String.format("%-10s",
+                             dayOfWeekCalendar.getDisplayName(
+                             Calendar.DAY_OF_WEEK, Calendar.LONG,
+                             Locale.getDefault())));
+                 }
+             },
+             new TAction() {
+                 public void DO() {
+                     dayOfWeekCalendar.add(Calendar.DAY_OF_WEEK, -1);
+                     dayOfWeekLabel.setLabel(String.format("%-10s",
+                             dayOfWeekCalendar.getDisplayName(
+                             Calendar.DAY_OF_WEEK, Calendar.LONG,
+                             Locale.getDefault())));
+                 }
+             }
+         );
+         addButton(i18n.getString("closeWindow"),
+             (getWidth() - 14) / 2, getHeight() - 4,
+             new TAction() {
+                 public void DO() {
+                     getApplication().closeWindow(DemoTextFieldWindow.this);
+                 }
+             }
+         );
+         activate(selected);
+         statusBar = newStatusBar(i18n.getString("statusBar"));
+         statusBar.addShortcutKeypress(kbF1, cmHelp,
+             i18n.getString("statusBarHelp"));
+         statusBar.addShortcutKeypress(kbF2, cmShell,
+             i18n.getString("statusBarShell"));
+         statusBar.addShortcutKeypress(kbF3, cmOpen,
+             i18n.getString("statusBarOpen"));
+         statusBar.addShortcutKeypress(kbF10, cmExit,
+             i18n.getString("statusBarExit"));
+     }
+ }
index 0000000000000000000000000000000000000000,5b42990f787c6909c5033e15c3a5ed24ca3b2ae6..5b42990f787c6909c5033e15c3a5ed24ca3b2ae6
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,17 +1,17 @@@
+ windowTitle=Text Fields
+ statusBar=Text fields
+ statusBarHelp=Help
+ statusBarShell=Shell
+ statusBarOpen=Open
+ statusBarExit=Exit
+ textField1=Variable-width text field:
+ textField2=Fixed-width text field:
+ textField3=Variable-width password:
+ textField4=Fixed-width password:
+ textField5=Very long text field:
+ textField6=Very very long field text that should be outside the window
+ calendarTitle=Calendar
+ calendarMessage=You selected the following date:\n\n{0}\n
+ closeWindow=&Close Window
index 0000000000000000000000000000000000000000,74908862eb0b2abaf68d571ac334bec04035628b..74908862eb0b2abaf68d571ac334bec04035628b
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,181 +1,181 @@@
+ /*
+  * Jexer - Java Text User Interface
+  *
+  * The MIT License (MIT)
+  *
+  * 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"),
+  * to deal in the Software without restriction, including without limitation
+  * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+  * and/or sell copies of the Software, and to permit persons to whom the
+  * Software is furnished to do so, subject to the following conditions:
+  *
+  * The above copyright notice and this permission notice shall be included in
+  * all copies or substantial portions of the Software.
+  *
+  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+  * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+  * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+  * DEALINGS IN THE SOFTWARE.
+  *
+  * @author Kevin Lamonte [kevin.lamonte@gmail.com]
+  * @version 1
+  */
+ package jexer.demos;
+ import java.util.ResourceBundle;
+ import jexer.TAction;
+ import jexer.TApplication;
+ import jexer.TText;
+ import jexer.TWidget;
+ import jexer.TWindow;
+ import jexer.event.TResizeEvent;
+ import jexer.menu.TMenu;
+ import static jexer.TCommand.*;
+ import static jexer.TKeypress.*;
+ /**
+  * This window demonstates the TText, THScroller, and TVScroller widgets.
+  */
+ public class DemoTextWindow extends TWindow {
+     /**
+      * Translated strings.
+      */
+     private static final ResourceBundle i18n = ResourceBundle.getBundle(DemoTextWindow.class.getName());
+     // ------------------------------------------------------------------------
+     // Variables --------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Hang onto my TText so I can resize it with the window.
+      */
+     private TText textField;
+     // ------------------------------------------------------------------------
+     // Constructors -----------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Public constructor makes a text window out of any string.
+      *
+      * @param parent the main application
+      * @param title the text string
+      * @param text the text string
+      */
+     public DemoTextWindow(final TApplication parent, final String title,
+         final String text) {
+         super(parent, title, 0, 0, 44, 22, RESIZABLE);
+         textField = addText(text, 1, 3, 40, 16);
+         addButton(i18n.getString("left"), 1, 1, new TAction() {
+                 public void DO() {
+                     textField.leftJustify();
+                 }
+         });
+         addButton(i18n.getString("center"), 10, 1, new TAction() {
+                 public void DO() {
+                     textField.centerJustify();
+                 }
+         });
+         addButton(i18n.getString("right"), 21, 1, new TAction() {
+                 public void DO() {
+                     textField.rightJustify();
+                 }
+         });
+         addButton(i18n.getString("full"), 31, 1, new TAction() {
+                 public void DO() {
+                     textField.fullJustify();
+                 }
+         });
+         statusBar = newStatusBar(i18n.getString("statusBar"));
+         statusBar.addShortcutKeypress(kbF1, cmHelp,
+             i18n.getString("statusBarHelp"));
+         statusBar.addShortcutKeypress(kbF2, cmShell,
+             i18n.getString("statusBarShell"));
+         statusBar.addShortcutKeypress(kbF3, cmOpen,
+             i18n.getString("statusBarOpen"));
+         statusBar.addShortcutKeypress(kbF10, cmExit,
+             i18n.getString("statusBarExit"));
+     }
+     /**
+      * Public constructor.
+      *
+      * @param parent the main application
+      */
+     public DemoTextWindow(final TApplication parent) {
+         this(parent, i18n.getString("windowTitle"),
+ "This is an example of a reflowable text field.  Some example text follows.\n" +
+ "\n" +
+ "Notice that some menu items should be disabled when this window has focus.\n" +
+ "\n" +
+ "This library implements a text-based windowing system loosely " +
+ "reminiscent of Borland's [Turbo " +
+ "Vision](http://en.wikipedia.org/wiki/Turbo_Vision) library.  For those " +
+ "wishing to use the actual C++ Turbo Vision library, see [Sergio " +
+ "Sigala's updated version](http://tvision.sourceforge.net/) that runs " +
+ "on many more platforms.\n" +
+ "\n" +
+ "This library is licensed MIT.  See the file LICENSE for the full license " +
+ "for the details.\n");
+     }
+     // ------------------------------------------------------------------------
+     // TWindow ----------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Handle window/screen resize events.
+      *
+      * @param event resize event
+      */
+     @Override
+     public void onResize(final TResizeEvent event) {
+         if (event.getType() == TResizeEvent.Type.WIDGET) {
+             // Resize the text field
+             TResizeEvent textSize = new TResizeEvent(TResizeEvent.Type.WIDGET,
+                 event.getWidth() - 4, event.getHeight() - 6);
+             textField.onResize(textSize);
+             return;
+         }
+         // Pass to children instead
+         for (TWidget widget: getChildren()) {
+             widget.onResize(event);
+         }
+     }
+     /**
+      * Play with menu items.
+      */
+     public void onFocus() {
+         getApplication().enableMenuItem(2001);
+         getApplication().disableMenuItem(TMenu.MID_SHELL);
+         getApplication().disableMenuItem(TMenu.MID_EXIT);
+     }
+     /**
+      * Called by application.switchWindow() when another window gets the
+      * focus.
+      */
+     public void onUnfocus() {
+         getApplication().disableMenuItem(2001);
+         getApplication().enableMenuItem(TMenu.MID_SHELL);
+         getApplication().enableMenuItem(TMenu.MID_EXIT);
+     }
+ }
index 0000000000000000000000000000000000000000,873a56f5295b0889ffcb41caf1938ba7beb21cfb..873a56f5295b0889ffcb41caf1938ba7beb21cfb
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,12 +1,12 @@@
+ windowTitle=Text Area
+ statusBar=Reflowable text window
+ statusBarHelp=Help
+ statusBarShell=Shell
+ statusBarOpen=Open
+ statusBarExit=Exit
+ left=&Left
+ center=&Center
+ right=&Right
+ full=&Full
index 0000000000000000000000000000000000000000,479895127edd6d3ab5e20dbd03e2cfa96720966b..479895127edd6d3ab5e20dbd03e2cfa96720966b
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,116 +1,116 @@@
+ /*
+  * Jexer - Java Text User Interface
+  *
+  * The MIT License (MIT)
+  *
+  * 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"),
+  * to deal in the Software without restriction, including without limitation
+  * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+  * and/or sell copies of the Software, and to permit persons to whom the
+  * Software is furnished to do so, subject to the following conditions:
+  *
+  * The above copyright notice and this permission notice shall be included in
+  * all copies or substantial portions of the Software.
+  *
+  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+  * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+  * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+  * DEALINGS IN THE SOFTWARE.
+  *
+  * @author Kevin Lamonte [kevin.lamonte@gmail.com]
+  * @version 1
+  */
+ package jexer.demos;
+ import java.io.IOException;
+ import java.util.ResourceBundle;
+ import jexer.TApplication;
+ import jexer.TWidget;
+ import jexer.TWindow;
+ import jexer.event.TResizeEvent;
+ import jexer.ttree.TDirectoryTreeItem;
+ import jexer.ttree.TTreeViewWidget;
+ import static jexer.TCommand.*;
+ import static jexer.TKeypress.*;
+ /**
+  * This window demonstates the TTreeView widget.
+  */
+ public class DemoTreeViewWindow extends TWindow {
+     /**
+      * Translated strings.
+      */
+     private static final ResourceBundle i18n = ResourceBundle.getBundle(DemoTreeViewWindow.class.getName());
+     // ------------------------------------------------------------------------
+     // Variables --------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Hang onto my TTreeView so I can resize it with the window.
+      */
+     private TTreeViewWidget treeView;
+     // ------------------------------------------------------------------------
+     // Constructors -----------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Public constructor.
+      *
+      * @param parent the main application
+      * @throws IOException if a java.io operation throws
+      */
+     public DemoTreeViewWindow(final TApplication parent) throws IOException {
+         super(parent, i18n.getString("windowTitle"), 0, 0, 44, 16,
+             TWindow.RESIZABLE);
+         // Load the treeview with "stuff"
+         treeView = addTreeViewWidget(1, 1, 40, 12);
+         new TDirectoryTreeItem(treeView, ".", true);
+         statusBar = newStatusBar(i18n.getString("statusBar"));
+         statusBar.addShortcutKeypress(kbF1, cmHelp,
+             i18n.getString("statusBarHelp"));
+         statusBar.addShortcutKeypress(kbF2, cmShell,
+             i18n.getString("statusBarShell"));
+         statusBar.addShortcutKeypress(kbF3, cmOpen,
+             i18n.getString("statusBarOpen"));
+         statusBar.addShortcutKeypress(kbF10, cmExit,
+             i18n.getString("statusBarExit"));
+     }
+     // ------------------------------------------------------------------------
+     // TWindow ----------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Handle window/screen resize events.
+      *
+      * @param resize resize event
+      */
+     @Override
+     public void onResize(final TResizeEvent resize) {
+         if (resize.getType() == TResizeEvent.Type.WIDGET) {
+             // Resize the treeView field
+             TResizeEvent treeSize = new TResizeEvent(TResizeEvent.Type.WIDGET,
+                 resize.getWidth() - 4, resize.getHeight() - 4);
+             treeView.onResize(treeSize);
+             return;
+         }
+         // Pass to children instead
+         for (TWidget widget: getChildren()) {
+             widget.onResize(resize);
+         }
+     }
+ }
index 0000000000000000000000000000000000000000,d63b24ea7fdc0290c1cc0776aee33d016584db94..d63b24ea7fdc0290c1cc0776aee33d016584db94
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,7 +1,7 @@@
+ windowTitle=Tree View
+ statusBar=Treeview demonstration
+ statusBarHelp=Help
+ statusBarShell=Shell
+ statusBarOpen=Open
+ statusBarExit=Exit
index 0000000000000000000000000000000000000000,520f5b07f6780c465dc5299e56fa2ead00270233..520f5b07f6780c465dc5299e56fa2ead00270233
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,75 +1,75 @@@
+ /*
+  * Jexer - Java Text User Interface
+  *
+  * The MIT License (MIT)
+  *
+  * 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"),
+  * to deal in the Software without restriction, including without limitation
+  * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+  * and/or sell copies of the Software, and to permit persons to whom the
+  * Software is furnished to do so, subject to the following conditions:
+  *
+  * The above copyright notice and this permission notice shall be included in
+  * all copies or substantial portions of the Software.
+  *
+  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+  * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+  * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+  * DEALINGS IN THE SOFTWARE.
+  *
+  * @author Kevin Lamonte [kevin.lamonte@gmail.com]
+  * @version 1
+  */
+ package jexer.demos;
+ import jexer.*;
+ /**
+  * The modified desktop.
+  */
+ public class DesktopDemo extends TDesktop {
+     // ------------------------------------------------------------------------
+     // Variables --------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * If true, draw the hatch.  Note package private access.
+      */
+     boolean drawHatch = true;
+     // ------------------------------------------------------------------------
+     // Constructors -----------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Public constructor.
+      *
+      * @param parent parent application
+      */
+     public DesktopDemo(final TApplication parent) {
+         super(parent);
+     }
+     // ------------------------------------------------------------------------
+     // TDesktop ---------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * The default TDesktop draws a hatch character across everything.  This
+      * version is selectable.
+      */
+     @Override
+     public void draw() {
+         if (drawHatch) {
+             super.draw();
+         }
+     }
+ }
index 0000000000000000000000000000000000000000,73d0c5f88ae028e8c83fe9b4355ed5c506d4ca50..73d0c5f88ae028e8c83fe9b4355ed5c506d4ca50
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,268 +1,268 @@@
+ /*
+  * Jexer - Java Text User Interface
+  *
+  * The MIT License (MIT)
+  *
+  * 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"),
+  * to deal in the Software without restriction, including without limitation
+  * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+  * and/or sell copies of the Software, and to permit persons to whom the
+  * Software is furnished to do so, subject to the following conditions:
+  *
+  * The above copyright notice and this permission notice shall be included in
+  * all copies or substantial portions of the Software.
+  *
+  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+  * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+  * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+  * DEALINGS IN THE SOFTWARE.
+  *
+  * @author Kevin Lamonte [kevin.lamonte@gmail.com]
+  * @version 1
+  */
+ package jexer.demos;
+ import java.io.File;
+ import java.io.IOException;
+ import java.util.ResourceBundle;
+ import java.util.Scanner;
+ import jexer.TAction;
+ import jexer.TApplication;
+ import jexer.TWindow;
+ import jexer.event.TMenuEvent;
+ import jexer.menu.TMenu;
+ /**
+  * The demo application itself.
+  */
+ public class DesktopDemoApplication extends TApplication {
+     /**
+      * Translated strings.
+      */
+     private static final ResourceBundle i18n = ResourceBundle.getBundle(DesktopDemoApplication.class.getName());
+     // ------------------------------------------------------------------------
+     // Constructors -----------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Public constructor.
+      *
+      * @param backendType one of the TApplication.BackendType values
+      * @throws Exception if TApplication can't instantiate the Backend.
+      */
+     public DesktopDemoApplication(final BackendType backendType) throws Exception {
+         super(backendType);
+         addAllWidgets();
+         getBackend().setTitle(i18n.getString("applicationTitle"));
+     }
+     // ------------------------------------------------------------------------
+     // TApplication -----------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Handle menu events.
+      *
+      * @param menu menu event
+      * @return if true, the event was processed and should not be passed onto
+      * a window
+      */
+     @Override
+     public boolean onMenu(final TMenuEvent menu) {
+         if (menu.getId() == TMenu.MID_OPEN_FILE) {
+             try {
+                 String filename = fileOpenBox(".");
+                  if (filename != null) {
+                      try {
+                          File file = new File(filename);
+                          StringBuilder fileContents = new StringBuilder();
+                          Scanner scanner = new Scanner(file);
+                          String EOL = System.getProperty("line.separator");
+                          try {
+                              while (scanner.hasNextLine()) {
+                                  fileContents.append(scanner.nextLine() + EOL);
+                              }
+                              new DemoTextWindow(this, filename,
+                                  fileContents.toString());
+                          } finally {
+                              scanner.close();
+                          }
+                      } catch (IOException e) {
+                          e.printStackTrace();
+                      }
+                  }
+             } catch (IOException e) {
+                 e.printStackTrace();
+             }
+             return true;
+         }
+         return super.onMenu(menu);
+     }
+     // ------------------------------------------------------------------------
+     // DesktopDemoApplication -------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Add all the widgets of the demo.
+      */
+     private void addAllWidgets() {
+         // Add the menus
+         addFileMenu();
+         addEditMenu();
+         addWindowMenu();
+         addHelpMenu();
+         final DesktopDemo desktop = new DesktopDemo(this);
+         setDesktop(desktop);
+         desktop.addButton(i18n.getString("removeHatch"), 2, 5,
+             new TAction() {
+                 public void DO() {
+                     desktop.drawHatch = false;
+                 }
+             }
+         );
+         desktop.addButton(i18n.getString("showHatch"), 2, 8,
+             new TAction() {
+                 public void DO() {
+                     desktop.drawHatch = true;
+                 }
+             }
+         );
+         final TWindow windowA = addWindow(i18n.getString("windowATitle"),
+             25, 14);
+         final TWindow windowB = addWindow(i18n.getString("windowBTitle"),
+             25, 14);
+         windowA.addButton(i18n.getString("showWindowB"), 2, 2,
+             new TAction() {
+                 public void DO() {
+                     windowB.show();
+                 }
+             }
+         );
+         windowA.addButton(i18n.getString("hideWindowB"), 2, 4,
+             new TAction() {
+                 public void DO() {
+                     windowB.hide();
+                 }
+             }
+         );
+         windowA.addButton(i18n.getString("maximizeWindowB"), 2, 6,
+             new TAction() {
+                 public void DO() {
+                     windowB.maximize();
+                 }
+             }
+         );
+         windowA.addButton(i18n.getString("restoreWindowB"), 2, 8,
+             new TAction() {
+                 public void DO() {
+                     windowB.restore();
+                 }
+             }
+         );
+         windowB.addButton(i18n.getString("showWindowA"), 2, 2,
+             new TAction() {
+                 public void DO() {
+                     windowA.show();
+                 }
+             }
+         );
+         windowB.addButton(i18n.getString("hideWindowA"), 2, 4,
+             new TAction() {
+                 public void DO() {
+                     windowA.hide();
+                 }
+             }
+         );
+         windowB.addButton(i18n.getString("maximizeWindowA"), 2, 6,
+             new TAction() {
+                 public void DO() {
+                     windowA.maximize();
+                 }
+             }
+         );
+         windowB.addButton(i18n.getString("restoreWindowA"), 2, 8,
+             new TAction() {
+                 public void DO() {
+                     windowA.restore();
+                 }
+             }
+         );
+         desktop.addButton(i18n.getString("showWindowB"), 25, 2,
+             new TAction() {
+                 public void DO() {
+                     windowB.show();
+                 }
+             }
+         );
+         desktop.addButton(i18n.getString("hideWindowB"), 25, 5,
+             new TAction() {
+                 public void DO() {
+                     windowB.hide();
+                 }
+             }
+         );
+         desktop.addButton(i18n.getString("showWindowA"), 25, 8,
+             new TAction() {
+                 public void DO() {
+                     windowA.show();
+                 }
+             }
+         );
+         desktop.addButton(i18n.getString("hideWindowA"), 25, 11,
+             new TAction() {
+                 public void DO() {
+                     windowA.hide();
+                 }
+             }
+         );
+         desktop.addButton(i18n.getString("createWindowC"), 25, 15,
+             new TAction() {
+                 public void DO() {
+                     final TWindow windowC = desktop.getApplication().addWindow(
+                         i18n.getString("windowCTitle"), 30, 20,
+                         TWindow.NOCLOSEBOX);
+                     windowC.addButton(i18n.getString("closeMe"), 5, 5,
+                         new TAction() {
+                             public void DO() {
+                                 windowC.close();
+                             }
+                         }
+                     );
+                 }
+             }
+         );
+         desktop.addButton(i18n.getString("enableFFM"), 25, 18,
+             new TAction() {
+                 public void DO() {
+                     DesktopDemoApplication.this.setFocusFollowsMouse(true);
+                 }
+             }
+         );
+         desktop.addButton(i18n.getString("disableFFM"), 25, 21,
+             new TAction() {
+                 public void DO() {
+                     DesktopDemoApplication.this.setFocusFollowsMouse(false);
+                 }
+             }
+         );
+     }
+ }
index 0000000000000000000000000000000000000000,85f74355f2c6cca19bded2560ab8362bf8f8a3f0..85f74355f2c6cca19bded2560ab8362bf8f8a3f0
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,19 +1,19 @@@
+ applicationTitle=Demo Application
+ removeHatch=Remove HATCH
+ showHatch=Show HATCH
+ closeMe=Close Me
+ createWindowC=Create Window C
+ disableFFM=Disable focusFollowsMouse
+ enableFFM=Enable focusFollowsMouse
+ hideWindowA=Hide Window A
+ hideWindowB=Hide Window B
+ maximizeWindowA=Maximize Window A
+ maximizeWindowB=Maximize Window B
+ restoreWindowA=Restore Window A
+ restoreWindowB=Restore Window B
+ showWindowA=Show Window A
+ showWindowB=Show Window B
+ windowATitle=Window A
+ windowBTitle=Window B
+ windowCTitle=Window C
index 0000000000000000000000000000000000000000,1305cddaafb690cd41251fe47a79f8b3afa868c7..1305cddaafb690cd41251fe47a79f8b3afa868c7
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,33 +1,33 @@@
+ /*
+  * Jexer - Java Text User Interface
+  *
+  * The MIT License (MIT)
+  *
+  * 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"),
+  * to deal in the Software without restriction, including without limitation
+  * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+  * and/or sell copies of the Software, and to permit persons to whom the
+  * Software is furnished to do so, subject to the following conditions:
+  *
+  * The above copyright notice and this permission notice shall be included in
+  * all copies or substantial portions of the Software.
+  *
+  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+  * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+  * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+  * DEALINGS IN THE SOFTWARE.
+  *
+  * @author Kevin Lamonte [kevin.lamonte@gmail.com]
+  * @version 1
+  */
+ /**
+  * Demonstration programs.
+  */
+ package jexer.demos;
index 0000000000000000000000000000000000000000,60f6385343889a74ce7d77a593ad9ae5775235f5..60f6385343889a74ce7d77a593ad9ae5775235f5
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,128 +1,128 @@@
+ /*
+  * Jexer - Java Text User Interface
+  *
+  * The MIT License (MIT)
+  *
+  * 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"),
+  * to deal in the Software without restriction, including without limitation
+  * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+  * and/or sell copies of the Software, and to permit persons to whom the
+  * Software is furnished to do so, subject to the following conditions:
+  *
+  * The above copyright notice and this permission notice shall be included in
+  * all copies or substantial portions of the Software.
+  *
+  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+  * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+  * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+  * DEALINGS IN THE SOFTWARE.
+  *
+  * @author Kevin Lamonte [kevin.lamonte@gmail.com]
+  * @version 1
+  */
+ package jexer.event;
+ import jexer.TCommand;
+ /**
+  * This class encapsulates a user command event.  User commands can be
+  * generated by menu actions, keyboard accelerators, and other UI elements.
+  * Commands can operate on both the application and individual widgets.
+  */
+ public class TCommandEvent extends TInputEvent {
+     // ------------------------------------------------------------------------
+     // Variables --------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Command dispatched.
+      */
+     private TCommand cmd;
+     // ------------------------------------------------------------------------
+     // Constructors -----------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Public contructor.
+      *
+      * @param cmd the TCommand dispatched
+      */
+     public TCommandEvent(final TCommand cmd) {
+         this.cmd = cmd;
+     }
+     // ------------------------------------------------------------------------
+     // TInputEvent ------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Comparison check.  All fields must match to return true.
+      *
+      * @param rhs another TCommandEvent or TCommand instance
+      * @return true if all fields are equal
+      */
+     @Override
+     public boolean equals(final Object rhs) {
+         if (!(rhs instanceof TCommandEvent)
+             && !(rhs instanceof TCommand)
+         ) {
+             return false;
+         }
+         if (rhs instanceof TCommandEvent) {
+             TCommandEvent that = (TCommandEvent) rhs;
+             return (cmd.equals(that.cmd)
+                 && (getTime().equals(that.getTime())));
+         }
+         TCommand that = (TCommand) rhs;
+         return (cmd.equals(that));
+     }
+     /**
+      * Hashcode uses all fields in equals().
+      *
+      * @return the hash
+      */
+     @Override
+     public int hashCode() {
+         int A = 13;
+         int B = 23;
+         int hash = A;
+         hash = (B * hash) + getTime().hashCode();
+         hash = (B * hash) + cmd.hashCode();
+         return hash;
+     }
+     /**
+      * Make human-readable description of this TCommandEvent.
+      *
+      * @return displayable String
+      */
+     @Override
+     public String toString() {
+         return String.format("CommandEvent: %s", cmd.toString());
+     }
+     // ------------------------------------------------------------------------
+     // TCommandEvent ----------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Get TCommand.
+      *
+      * @return the TCommand
+      */
+     public TCommand getCmd() {
+         return cmd;
+     }
+ }
index 0000000000000000000000000000000000000000,220512fab1f8ccfee3443e23209b822241dbed4b..220512fab1f8ccfee3443e23209b822241dbed4b
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,72 +1,72 @@@
+ /*
+  * Jexer - Java Text User Interface
+  *
+  * The MIT License (MIT)
+  *
+  * 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"),
+  * to deal in the Software without restriction, including without limitation
+  * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+  * and/or sell copies of the Software, and to permit persons to whom the
+  * Software is furnished to do so, subject to the following conditions:
+  *
+  * The above copyright notice and this permission notice shall be included in
+  * all copies or substantial portions of the Software.
+  *
+  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+  * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+  * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+  * DEALINGS IN THE SOFTWARE.
+  *
+  * @author Kevin Lamonte [kevin.lamonte@gmail.com]
+  * @version 1
+  */
+ package jexer.event;
+ import java.util.Date;
+ /**
+  * This is the parent class of all events dispatched to the UI.
+  */
+ public abstract class TInputEvent {
+     // ------------------------------------------------------------------------
+     // Variables --------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Time at which event was generated.
+      */
+     private Date time;
+     // ------------------------------------------------------------------------
+     // Constructors -----------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Protected contructor.
+      */
+     protected TInputEvent() {
+         // Save the current time
+         time = new Date();
+     }
+     // ------------------------------------------------------------------------
+     // TInputEvent ------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Get time.
+      *
+      * @return the time that this event was generated
+      */
+     public final Date getTime() {
+         return time;
+     }
+ }
index 0000000000000000000000000000000000000000,79b28f29381655022f879ef0eccd0f472d50307d..79b28f29381655022f879ef0eccd0f472d50307d
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,167 +1,167 @@@
+ /*
+  * Jexer - Java Text User Interface
+  *
+  * The MIT License (MIT)
+  *
+  * 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"),
+  * to deal in the Software without restriction, including without limitation
+  * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+  * and/or sell copies of the Software, and to permit persons to whom the
+  * Software is furnished to do so, subject to the following conditions:
+  *
+  * The above copyright notice and this permission notice shall be included in
+  * all copies or substantial portions of the Software.
+  *
+  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+  * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+  * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+  * DEALINGS IN THE SOFTWARE.
+  *
+  * @author Kevin Lamonte [kevin.lamonte@gmail.com]
+  * @version 1
+  */
+ package jexer.event;
+ import jexer.TKeypress;
+ /**
+  * This class encapsulates a keyboard input event.
+  */
+ public class TKeypressEvent extends TInputEvent {
+     // ------------------------------------------------------------------------
+     // Variables --------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Keystroke received.
+      */
+     private TKeypress key;
+     // ------------------------------------------------------------------------
+     // Constructors -----------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Public contructor.
+      *
+      * @param key the TKeypress received
+      */
+     public TKeypressEvent(final TKeypress key) {
+         this.key = key;
+     }
+     /**
+      * Public constructor.
+      *
+      * @param isKey is true, this is a function key
+      * @param fnKey the function key code (only valid if isKey is true)
+      * @param ch the character (only valid if fnKey is false)
+      * @param alt if true, ALT was pressed with this keystroke
+      * @param ctrl if true, CTRL was pressed with this keystroke
+      * @param shift if true, SHIFT was pressed with this keystroke
+      */
+     public TKeypressEvent(final boolean isKey, final int fnKey, final int ch,
+         final boolean alt, final boolean ctrl, final boolean shift) {
+         this.key = new TKeypress(isKey, fnKey, ch, alt, ctrl, shift);
+     }
+     /**
+      * Public constructor.
+      *
+      * @param key the TKeypress received
+      * @param alt if true, ALT was pressed with this keystroke
+      * @param ctrl if true, CTRL was pressed with this keystroke
+      * @param shift if true, SHIFT was pressed with this keystroke
+      */
+     public TKeypressEvent(final TKeypress key,
+         final boolean alt, final boolean ctrl, final boolean shift) {
+         this.key = new TKeypress(key.isFnKey(), key.getKeyCode(), key.getChar(),
+             alt, ctrl, shift);
+     }
+     // ------------------------------------------------------------------------
+     // TInputEvent ------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Comparison check.  All fields must match to return true.
+      *
+      * @param rhs another TKeypressEvent or TKeypress instance
+      * @return true if all fields are equal
+      */
+     @Override
+     public boolean equals(final Object rhs) {
+         if (!(rhs instanceof TKeypressEvent)
+             && !(rhs instanceof TKeypress)
+         ) {
+             return false;
+         }
+         if (rhs instanceof TKeypressEvent) {
+             TKeypressEvent that = (TKeypressEvent) rhs;
+             return (key.equals(that.key)
+                 && (getTime().equals(that.getTime())));
+         }
+         TKeypress that = (TKeypress) rhs;
+         return (key.equals(that));
+     }
+     /**
+      * Hashcode uses all fields in equals().
+      *
+      * @return the hash
+      */
+     @Override
+     public int hashCode() {
+         int A = 13;
+         int B = 23;
+         int hash = A;
+         hash = (B * hash) + getTime().hashCode();
+         hash = (B * hash) + key.hashCode();
+         return hash;
+     }
+     /**
+      * Make human-readable description of this TKeypressEvent.
+      *
+      * @return displayable String
+      */
+     @Override
+     public String toString() {
+         return String.format("Keypress: %s", key.toString());
+     }
+     // ------------------------------------------------------------------------
+     // TKeypressEvent ---------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Get keystroke.
+      *
+      * @return keystroke
+      */
+     public TKeypress getKey() {
+         return key;
+     }
+     /**
+      * Create a duplicate instance.
+      *
+      * @return duplicate intance
+      */
+     public TKeypressEvent dup() {
+         TKeypressEvent keypress = new TKeypressEvent(key.dup());
+         return keypress;
+     }
+ }
index 0000000000000000000000000000000000000000,e2ff7c74b6a1125a19e91e15685f9f5b3b408452..e2ff7c74b6a1125a19e91e15685f9f5b3b408452
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,87 +1,87 @@@
+ /*
+  * Jexer - Java Text User Interface
+  *
+  * The MIT License (MIT)
+  *
+  * 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"),
+  * to deal in the Software without restriction, including without limitation
+  * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+  * and/or sell copies of the Software, and to permit persons to whom the
+  * Software is furnished to do so, subject to the following conditions:
+  *
+  * The above copyright notice and this permission notice shall be included in
+  * all copies or substantial portions of the Software.
+  *
+  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+  * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+  * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+  * DEALINGS IN THE SOFTWARE.
+  *
+  * @author Kevin Lamonte [kevin.lamonte@gmail.com]
+  * @version 1
+  */
+ package jexer.event;
+ /**
+  * This class encapsulates a menu selection event.
+  * TApplication.getMenuItem(id) can be used to obtain the TMenuItem itself,
+  * say for setting enabled/disabled/checked/etc.
+  */
+ public class TMenuEvent extends TInputEvent {
+     // ------------------------------------------------------------------------
+     // Variables --------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * MenuItem ID.
+      */
+     private int id;
+     // ------------------------------------------------------------------------
+     // Constructors -----------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Public contructor.
+      *
+      * @param id the MenuItem ID
+      */
+     public TMenuEvent(final int id) {
+         this.id = id;
+     }
+     // ------------------------------------------------------------------------
+     // TInputEvent ------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Make human-readable description of this TMenuEvent.
+      *
+      * @return displayable String
+      */
+     @Override
+     public String toString() {
+         return String.format("MenuEvent: %d", id);
+     }
+     // ------------------------------------------------------------------------
+     // TMenuEvent -------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Get the MenuItem ID.
+      *
+      * @return the ID
+      */
+     public int getId() {
+         return id;
+     }
+ }
index 0000000000000000000000000000000000000000,496d8bc06422baa3014a95a98ae953f60cd4325e..496d8bc06422baa3014a95a98ae953f60cd4325e
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,321 +1,321 @@@
+ /*
+  * Jexer - Java Text User Interface
+  *
+  * The MIT License (MIT)
+  *
+  * 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"),
+  * to deal in the Software without restriction, including without limitation
+  * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+  * and/or sell copies of the Software, and to permit persons to whom the
+  * Software is furnished to do so, subject to the following conditions:
+  *
+  * The above copyright notice and this permission notice shall be included in
+  * all copies or substantial portions of the Software.
+  *
+  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+  * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+  * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+  * DEALINGS IN THE SOFTWARE.
+  *
+  * @author Kevin Lamonte [kevin.lamonte@gmail.com]
+  * @version 1
+  */
+ package jexer.event;
+ /**
+  * This class encapsulates several kinds of mouse input events.  Note that
+  * the relative (x,y) ARE MUTABLE: TWidget's onMouse() handlers perform that
+  * update during event dispatching.
+  */
+ public class TMouseEvent extends TInputEvent {
+     // ------------------------------------------------------------------------
+     // Constants --------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * The type of event generated.
+      */
+     public enum Type {
+         /**
+          * Mouse motion.  X and Y will have screen coordinates.
+          */
+         MOUSE_MOTION,
+         /**
+          * Mouse button down.  X and Y will have screen coordinates.
+          */
+         MOUSE_DOWN,
+         /**
+          * Mouse button up.  X and Y will have screen coordinates.
+          */
+         MOUSE_UP,
+         /**
+          * Mouse double-click.  X and Y will have screen coordinates.
+          */
+         MOUSE_DOUBLE_CLICK
+     }
+     // ------------------------------------------------------------------------
+     // Variables --------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Type of event, one of MOUSE_MOTION, MOUSE_UP, or MOUSE_DOWN.
+      */
+     private Type type;
+     /**
+      * Mouse X - relative coordinates.
+      */
+     private int x;
+     /**
+      * Mouse Y - relative coordinates.
+      */
+     private int y;
+     /**
+      * Mouse X - absolute screen coordinates.
+      */
+     private int absoluteX;
+     /**
+      * Mouse Y - absolute screen coordinate.
+      */
+     private int absoluteY;
+     /**
+      * Mouse button 1 (left button).
+      */
+     private boolean mouse1;
+     /**
+      * Mouse button 2 (right button).
+      */
+     private boolean mouse2;
+     /**
+      * Mouse button 3 (middle button).
+      */
+     private boolean mouse3;
+     /**
+      * Mouse wheel UP (button 4).
+      */
+     private boolean mouseWheelUp;
+     /**
+      * Mouse wheel DOWN (button 5).
+      */
+     private boolean mouseWheelDown;
+     // ------------------------------------------------------------------------
+     // Constructors -----------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Public contructor.
+      *
+      * @param type the type of event, MOUSE_MOTION, MOUSE_DOWN, or MOUSE_UP
+      * @param x relative column
+      * @param y relative row
+      * @param absoluteX absolute column
+      * @param absoluteY absolute row
+      * @param mouse1 if true, left button is down
+      * @param mouse2 if true, right button is down
+      * @param mouse3 if true, middle button is down
+      * @param mouseWheelUp if true, mouse wheel (button 4) is down
+      * @param mouseWheelDown if true, mouse wheel (button 5) is down
+      */
+     public TMouseEvent(final Type type, final int x, final int y,
+         final int absoluteX, final int absoluteY,
+         final boolean mouse1, final boolean mouse2, final boolean mouse3,
+         final boolean mouseWheelUp, final boolean mouseWheelDown) {
+         this.type               = type;
+         this.x                  = x;
+         this.y                  = y;
+         this.absoluteX          = absoluteX;
+         this.absoluteY          = absoluteY;
+         this.mouse1             = mouse1;
+         this.mouse2             = mouse2;
+         this.mouse3             = mouse3;
+         this.mouseWheelUp       = mouseWheelUp;
+         this.mouseWheelDown     = mouseWheelDown;
+     }
+     // ------------------------------------------------------------------------
+     // TMouseEvent ------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Get type.
+      *
+      * @return type
+      */
+     public Type getType() {
+         return type;
+     }
+     /**
+      * Get x.
+      *
+      * @return x
+      */
+     public int getX() {
+         return x;
+     }
+     /**
+      * Set x.
+      *
+      * @param x new relative X value
+      * @see jexer.TWidget#onMouseDown(TMouseEvent mouse)
+      * @see jexer.TWidget#onMouseDown(TMouseEvent mouse)
+      * @see jexer.TWidget#onMouseMotion(TMouseEvent mouse)
+      */
+     public void setX(final int x) {
+         this.x = x;
+     }
+     /**
+      * Get y.
+      *
+      * @return y
+      */
+     public int getY() {
+         return y;
+     }
+     /**
+      * Set y.
+      *
+      * @param y new relative Y value
+      * @see jexer.TWidget#onMouseDown(TMouseEvent mouse)
+      * @see jexer.TWidget#onMouseDown(TMouseEvent mouse)
+      * @see jexer.TWidget#onMouseMotion(TMouseEvent mouse)
+      */
+     public void setY(final int y) {
+         this.y = y;
+     }
+     /**
+      * Get absoluteX.
+      *
+      * @return absoluteX
+      */
+     public int getAbsoluteX() {
+         return absoluteX;
+     }
+     /**
+      * Set absoluteX.
+      *
+      * @param absoluteX the new value
+      */
+     public void setAbsoluteX(final int absoluteX) {
+         this.absoluteX = absoluteX;
+     }
+     /**
+      * Get absoluteY.
+      *
+      * @return absoluteY
+      */
+     public int getAbsoluteY() {
+         return absoluteY;
+     }
+     /**
+      * Set absoluteY.
+      *
+      * @param absoluteY the new value
+      */
+     public void setAbsoluteY(final int absoluteY) {
+         this.absoluteY = absoluteY;
+     }
+     /**
+      * Get mouse1.
+      *
+      * @return mouse1
+      */
+     public boolean isMouse1() {
+         return mouse1;
+     }
+     /**
+      * Get mouse2.
+      *
+      * @return mouse2
+      */
+     public boolean isMouse2() {
+         return mouse2;
+     }
+     /**
+      * Get mouse3.
+      *
+      * @return mouse3
+      */
+     public boolean isMouse3() {
+         return mouse3;
+     }
+     /**
+      * Get mouseWheelUp.
+      *
+      * @return mouseWheelUp
+      */
+     public boolean isMouseWheelUp() {
+         return mouseWheelUp;
+     }
+     /**
+      * Get mouseWheelDown.
+      *
+      * @return mouseWheelDown
+      */
+     public boolean isMouseWheelDown() {
+         return mouseWheelDown;
+     }
+     /**
+      * Create a duplicate instance.
+      *
+      * @return duplicate intance
+      */
+     public TMouseEvent dup() {
+         TMouseEvent mouse = new TMouseEvent(type, x, y, absoluteX, absoluteY,
+             mouse1, mouse2, mouse3, mouseWheelUp, mouseWheelDown);
+         return mouse;
+     }
+     /**
+      * Make human-readable description of this TMouseEvent.
+      *
+      * @return displayable String
+      */
+     @Override
+     public String toString() {
+         return String.format("Mouse: %s x %d y %d absoluteX %d absoluteY %d 1 %s 2 %s 3 %s DOWN %s UP %s",
+             type,
+             x, y,
+             absoluteX, absoluteY,
+             mouse1,
+             mouse2,
+             mouse3,
+             mouseWheelUp,
+             mouseWheelDown);
+     }
+ }
index 0000000000000000000000000000000000000000,ff9571084232f1891258c1b0af02758f231fabb1..ff9571084232f1891258c1b0af02758f231fabb1
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,134 +1,134 @@@
+ /*
+  * Jexer - Java Text User Interface
+  *
+  * The MIT License (MIT)
+  *
+  * 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"),
+  * to deal in the Software without restriction, including without limitation
+  * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+  * and/or sell copies of the Software, and to permit persons to whom the
+  * Software is furnished to do so, subject to the following conditions:
+  *
+  * The above copyright notice and this permission notice shall be included in
+  * all copies or substantial portions of the Software.
+  *
+  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+  * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+  * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+  * DEALINGS IN THE SOFTWARE.
+  *
+  * @author Kevin Lamonte [kevin.lamonte@gmail.com]
+  * @version 1
+  */
+ package jexer.event;
+ /**
+  * This class encapsulates a screen or window resize event.
+  */
+ public class TResizeEvent extends TInputEvent {
+     // ------------------------------------------------------------------------
+     // Constants --------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Resize events can be generated for either a total screen resize or a
+      * widget/window resize.
+      */
+     public enum Type {
+         /**
+          * The entire screen size changed.
+          */
+         SCREEN,
+         /**
+          * A widget was resized.
+          */
+         WIDGET
+     }
+     // ------------------------------------------------------------------------
+     // Variables --------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * The type of resize.
+      */
+     private Type type;
+     /**
+      * New width.
+      */
+     private int width;
+     /**
+      * New height.
+      */
+     private int height;
+     // ------------------------------------------------------------------------
+     // Constructors -----------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Public contructor.
+      *
+      * @param type the Type of resize, Screen or Widget
+      * @param width the new width
+      * @param height the new height
+      */
+     public TResizeEvent(final Type type, final int width, final int height) {
+         this.type   = type;
+         this.width  = width;
+         this.height = height;
+     }
+     // ------------------------------------------------------------------------
+     // TResizeEvent -----------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Get resize type.
+      *
+      * @return SCREEN or WIDGET
+      */
+     public Type getType() {
+         return type;
+     }
+     /**
+      * Get the new width.
+      *
+      * @return width
+      */
+     public int getWidth() {
+         return width;
+     }
+     /**
+      * Get the new height.
+      *
+      * @return height
+      */
+     public int getHeight() {
+         return height;
+     }
+     /**
+      * Make human-readable description of this TResizeEvent.
+      *
+      * @return displayable String
+      */
+     @Override
+     public String toString() {
+         return String.format("Resize: %s width = %d height = %d",
+             type, width, height);
+     }
+ }
index 0000000000000000000000000000000000000000,e4541a3b9e0daac4a5061f9df1d89ad694df1c4f..e4541a3b9e0daac4a5061f9df1d89ad694df1c4f
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,34 +1,34 @@@
+ /*
+  * Jexer - Java Text User Interface
+  *
+  * The MIT License (MIT)
+  *
+  * 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"),
+  * to deal in the Software without restriction, including without limitation
+  * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+  * and/or sell copies of the Software, and to permit persons to whom the
+  * Software is furnished to do so, subject to the following conditions:
+  *
+  * The above copyright notice and this permission notice shall be included in
+  * all copies or substantial portions of the Software.
+  *
+  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+  * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+  * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+  * DEALINGS IN THE SOFTWARE.
+  *
+  * @author Kevin Lamonte [kevin.lamonte@gmail.com]
+  * @version 1
+  */
+ /**
+  * Events that are generated by both end-user I/O (keyboard/mouse) and other
+  * UI elements (menu/resize).
+  */
+ package jexer.event;
index 0000000000000000000000000000000000000000,8c6371e267b2c98069fe423ac1450481f56b6e7c..8c6371e267b2c98069fe423ac1450481f56b6e7c
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,52 +1,52 @@@
+ /*
+  * Jexer - Java Text User Interface
+  *
+  * The MIT License (MIT)
+  *
+  * 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"),
+  * to deal in the Software without restriction, including without limitation
+  * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+  * and/or sell copies of the Software, and to permit persons to whom the
+  * Software is furnished to do so, subject to the following conditions:
+  *
+  * The above copyright notice and this permission notice shall be included in
+  * all copies or substantial portions of the Software.
+  *
+  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+  * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+  * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+  * DEALINGS IN THE SOFTWARE.
+  *
+  * @author Kevin Lamonte [kevin.lamonte@gmail.com]
+  * @version 1
+  */
+ package jexer.io;
+ import java.io.IOException;
+ /**
+  * ReadTimeoutException is thrown by TimeoutInputStream.read() when bytes are
+  * not available within the timeout specified.
+  */
+ public class ReadTimeoutException extends IOException {
+     /**
+      * Serializable version.
+      */
+     private static final long serialVersionUID = 1;
+     /**
+      * Construct an instance with a message.
+      *
+      * @param msg exception text
+      */
+     public ReadTimeoutException(String msg) {
+         super(msg);
+     }
+ }
index 0000000000000000000000000000000000000000,3d8cdb0312494293b5e90bdeee39c5f21b32f659..3d8cdb0312494293b5e90bdeee39c5f21b32f659
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,393 +1,393 @@@
+ /*
+  * Jexer - Java Text User Interface
+  *
+  * The MIT License (MIT)
+  *
+  * 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"),
+  * to deal in the Software without restriction, including without limitation
+  * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+  * and/or sell copies of the Software, and to permit persons to whom the
+  * Software is furnished to do so, subject to the following conditions:
+  *
+  * The above copyright notice and this permission notice shall be included in
+  * all copies or substantial portions of the Software.
+  *
+  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+  * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+  * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+  * DEALINGS IN THE SOFTWARE.
+  *
+  * @author Kevin Lamonte [kevin.lamonte@gmail.com]
+  * @version 1
+  */
+ package jexer.io;
+ import java.io.IOException;
+ import java.io.InputStream;
+ /**
+  * This class provides an optional millisecond timeout on its read()
+  * operations.  This permits callers to bail out rather than block.
+  */
+ public class TimeoutInputStream extends InputStream {
+     // ------------------------------------------------------------------------
+     // Variables --------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * The wrapped stream.
+      */
+     private InputStream stream;
+     /**
+      * The timeout value in millis.  If it takes longer than this for bytes
+      * to be available for read then a ReadTimeoutException is thrown.  A
+      * value of 0 means to block as a normal InputStream would.
+      */
+     private int timeoutMillis;
+     /**
+      * If true, the current read() will timeout soon.
+      */
+     private volatile boolean cancel = false;
+     // ------------------------------------------------------------------------
+     // Constructors -----------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Public constructor, at the default timeout of 10000 millis (10
+      * seconds).
+      *
+      * @param stream the wrapped InputStream
+      */
+     public TimeoutInputStream(final InputStream stream) {
+         this.stream             = stream;
+         this.timeoutMillis      = 10000;
+     }
+     /**
+      * Public constructor.
+      *
+      * @param stream the wrapped InputStream
+      * @param timeoutMillis the timeout value in millis.  If it takes longer
+      * than this for bytes to be available for read then a
+      * ReadTimeoutException is thrown.  A value of 0 means to block as a
+      * normal InputStream would.
+      */
+     public TimeoutInputStream(final InputStream stream,
+         final int timeoutMillis) {
+         if (timeoutMillis < 0) {
+             throw new IllegalArgumentException("Invalid timeoutMillis value, " +
+                 "must be >= 0");
+         }
+         this.stream             = stream;
+         this.timeoutMillis      = timeoutMillis;
+     }
+     // ------------------------------------------------------------------------
+     // InputStream ------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Reads the next byte of data from the input stream.
+      *
+      * @return the next byte of data, or -1 if there is no more data because
+      * the end of the stream has been reached.
+      * @throws IOException if an I/O error occurs
+      */
+     @Override
+     public int read() throws IOException {
+         if (timeoutMillis == 0) {
+             // Block on the read().
+             return stream.read();
+         }
+         if (stream.available() > 0) {
+             // A byte is available now, return it.
+             return stream.read();
+         }
+         // We will wait up to timeoutMillis to see if a byte is available.
+         // If not, we throw ReadTimeoutException.
+         long checkTime = System.currentTimeMillis();
+         while (stream.available() == 0) {
+             long now = System.currentTimeMillis();
+             synchronized (this) {
+                 if ((now - checkTime > timeoutMillis) || (cancel == true)) {
+                     if (cancel == true) {
+                         cancel = false;
+                     }
+                     throw new ReadTimeoutException("Timeout on read(): " +
+                         (int) (now - checkTime) + " millis and still no data");
+                 }
+             }
+             try {
+                 // How long do we sleep for, eh?  For now we will go with 2
+                 // millis.
+                 Thread.sleep(2);
+             } catch (InterruptedException e) {
+                 // SQUASH
+             }
+         }
+         if (stream.available() > 0) {
+             // A byte is available now, return it.
+             return stream.read();
+         }
+         throw new IOException("InputStream claimed a byte was available, but " +
+             "now it is not.  What is going on?");
+     }
+     /**
+      * Reads some number of bytes from the input stream and stores them into
+      * the buffer array b.
+      *
+      * @param b the buffer into which the data is read.
+      * @return the total number of bytes read into the buffer, or -1 if there
+      * is no more data because the end of the stream has been reached.
+      * @throws IOException if an I/O error occurs
+      */
+     @Override
+     public int read(final byte[] b) throws IOException {
+         if (timeoutMillis == 0) {
+             // Block on the read().
+             return stream.read(b);
+         }
+         int remaining = b.length;
+         if (stream.available() >= remaining) {
+             // Enough bytes are available now, return them.
+             return stream.read(b);
+         }
+         while (remaining > 0) {
+             // We will wait up to timeoutMillis to see if a byte is
+             // available.  If not, we throw ReadTimeoutException.
+             long checkTime = System.currentTimeMillis();
+             while (stream.available() == 0) {
+                 if (remaining > 0) {
+                     return (b.length - remaining);
+                 }
+                 long now = System.currentTimeMillis();
+                 synchronized (this) {
+                     if ((now - checkTime > timeoutMillis) || (cancel == true)) {
+                         if (cancel == true) {
+                             cancel = false;
+                         }
+                         throw new ReadTimeoutException("Timeout on read(): " +
+                             (int) (now - checkTime) + " millis and still no " +
+                             "data");
+                     }
+                 }
+                 try {
+                     // How long do we sleep for, eh?  For now we will go with
+                     // 2 millis.
+                     Thread.sleep(2);
+                 } catch (InterruptedException e) {
+                     // SQUASH
+                 }
+             }
+             if (stream.available() > 0) {
+                 // At least one byte is available now, read it.
+                 int n = stream.available();
+                 if (remaining < n) {
+                     n = remaining;
+                 }
+                 int rc = stream.read(b, b.length - remaining, n);
+                 if (rc == -1) {
+                     // This shouldn't happen.
+                     throw new IOException("InputStream claimed bytes were " +
+                         "available, but read() returned -1.  What is going " +
+                         "on?");
+                 }
+                 remaining -= rc;
+                 if (remaining == 0) {
+                     return b.length;
+                 }
+             }
+         }
+         throw new IOException("InputStream claimed all bytes were available, " +
+             "but now it is not.  What is going on?");
+     }
+     /**
+      * Reads up to len bytes of data from the input stream into an array of
+      * bytes.
+      *
+      * @param b the buffer into which the data is read.
+      * @param off the start offset in array b at which the data is written.
+      * @param len the maximum number of bytes to read.
+      * @return the total number of bytes read into the buffer, or -1 if there
+      * is no more data because the end of the stream has been reached.
+      * @throws IOException if an I/O error occurs
+      */
+     @Override
+     public int read(final byte[] b, final int off,
+         final int len) throws IOException {
+         if (timeoutMillis == 0) {
+             // Block on the read().
+             return stream.read(b);
+         }
+         int remaining = len;
+         if (stream.available() >= remaining) {
+             // Enough bytes are available now, return them.
+             return stream.read(b, off, remaining);
+         }
+         while (remaining > 0) {
+             // We will wait up to timeoutMillis to see if a byte is
+             // available.  If not, we throw ReadTimeoutException.
+             long checkTime = System.currentTimeMillis();
+             while (stream.available() == 0) {
+                 if (remaining > 0) {
+                     return (len - remaining);
+                 }
+                 long now = System.currentTimeMillis();
+                 synchronized (this) {
+                     if ((now - checkTime > timeoutMillis) || (cancel == true)) {
+                         if (cancel == true) {
+                             cancel = false;
+                         }
+                         throw new ReadTimeoutException("Timeout on read(): " +
+                             (int) (now - checkTime) + " millis and still no " +
+                             "data");
+                     }
+                 }
+                 try {
+                     // How long do we sleep for, eh?  For now we will go with
+                     // 2 millis.
+                     Thread.sleep(2);
+                 } catch (InterruptedException e) {
+                     // SQUASH
+                 }
+             }
+             if (stream.available() > 0) {
+                 // At least one byte is available now, read it.
+                 int n = stream.available();
+                 if (remaining < n) {
+                     n = remaining;
+                 }
+                 int rc = stream.read(b, off + len - remaining, n);
+                 if (rc == -1) {
+                     // This shouldn't happen.
+                     throw new IOException("InputStream claimed bytes were " +
+                         "available, but read() returned -1.  What is going " +
+                         "on?");
+                 }
+                 remaining -= rc;
+                 if (remaining == 0) {
+                     return len;
+                 }
+             }
+         }
+         throw new IOException("InputStream claimed all bytes were available, " +
+             "but now it is not.  What is going on?");
+     }
+     /**
+      * Returns an estimate of the number of bytes that can be read (or
+      * skipped over) from this input stream without blocking by the next
+      * invocation of a method for this input stream.
+      *
+      * @return an estimate of the number of bytes that can be read (or
+      * skipped over) from this input stream without blocking or 0 when it
+      * reaches the end of the input stream.
+      * @throws IOException if an I/O error occurs
+      */
+     @Override
+     public int available() throws IOException {
+         return stream.available();
+     }
+     /**
+      * Closes this input stream and releases any system resources associated
+      * with the stream.
+      *
+      * @throws IOException if an I/O error occurs
+      */
+     @Override
+     public void close() throws IOException {
+         stream.close();
+     }
+     /**
+      * Marks the current position in this input stream.
+      *
+      * @param readLimit the maximum limit of bytes that can be read before
+      * the mark position becomes invalid
+      */
+     @Override
+     public void mark(final int readLimit) {
+         stream.mark(readLimit);
+     }
+     /**
+      * Tests if this input stream supports the mark and reset methods.
+      *
+      * @return true if this stream instance supports the mark and reset
+      * methods; false otherwise
+      */
+     @Override
+     public boolean markSupported() {
+         return stream.markSupported();
+     }
+     /**
+      * Repositions this stream to the position at the time the mark method
+      * was last called on this input stream.
+      *
+      * @throws IOException if an I/O error occurs
+      */
+     @Override
+     public void reset() throws IOException {
+         stream.reset();
+     }
+     /**
+      * Skips over and discards n bytes of data from this input stream.
+      *
+      * @param n the number of bytes to be skipped
+      * @return the actual number of bytes skipped
+      * @throws IOException if an I/O error occurs
+      */
+     @Override
+     public long skip(final long n) throws IOException {
+         return stream.skip(n);
+     }
+     // ------------------------------------------------------------------------
+     // TimeoutInputStream -----------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Request that the current read() operation timeout immediately.
+      */
+     public synchronized void cancelRead() {
+         cancel = true;
+     }
+ }
index 0000000000000000000000000000000000000000,37ad2bbe9987adc1d6d8bb3b1e840a9289d1b516..37ad2bbe9987adc1d6d8bb3b1e840a9289d1b516
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,33 +1,33 @@@
+ /*
+  * Jexer - Java Text User Interface
+  *
+  * The MIT License (MIT)
+  *
+  * 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"),
+  * to deal in the Software without restriction, including without limitation
+  * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+  * and/or sell copies of the Software, and to permit persons to whom the
+  * Software is furnished to do so, subject to the following conditions:
+  *
+  * The above copyright notice and this permission notice shall be included in
+  * all copies or substantial portions of the Software.
+  *
+  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+  * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+  * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+  * DEALINGS IN THE SOFTWARE.
+  *
+  * @author Kevin Lamonte [kevin.lamonte@gmail.com]
+  * @version 1
+  */
+ /**
+  * java.io subclasses.
+  */
+ package jexer.io;
index 0000000000000000000000000000000000000000,057127fa2277e389a91c9f9e3ea5813084e918a3..057127fa2277e389a91c9f9e3ea5813084e918a3
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,170 +1,170 @@@
+ /*
+  * Jexer - Java Text User Interface
+  *
+  * The MIT License (MIT)
+  *
+  * 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"),
+  * to deal in the Software without restriction, including without limitation
+  * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+  * and/or sell copies of the Software, and to permit persons to whom the
+  * Software is furnished to do so, subject to the following conditions:
+  *
+  * The above copyright notice and this permission notice shall be included in
+  * all copies or substantial portions of the Software.
+  *
+  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+  * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+  * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+  * DEALINGS IN THE SOFTWARE.
+  *
+  * @author Kevin Lamonte [kevin.lamonte@gmail.com]
+  * @version 1
+  */
+ package jexer.layout;
+ import java.util.ArrayList;
+ import jexer.TWidget;
+ import jexer.event.TResizeEvent;
+ /**
+  * BoxLayoutManager repositions child widgets based on the order they are
+  * added to the parent widget and desired orientation.
+  */
+ public class BoxLayoutManager implements LayoutManager {
+     // ------------------------------------------------------------------------
+     // Variables --------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * If true, orient vertically.  If false, orient horizontally.
+      */
+     private boolean vertical = true;
+     /**
+      * Current width.
+      */
+     private int width = 0;
+     /**
+      * Current height.
+      */
+     private int height = 0;
+     /**
+      * Widgets being managed.
+      */
+     private ArrayList<TWidget> children = new ArrayList<TWidget>();
+     // ------------------------------------------------------------------------
+     // Constructors -----------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Public constructor.
+      *
+      * @param width the width of the parent widget
+      * @param height the height of the parent widget
+      * @param vertical if true, arrange widgets vertically
+      */
+     public BoxLayoutManager(final int width, final int height,
+         final boolean vertical) {
+         this.width = width;
+         this.height = height;
+         this.vertical = vertical;
+     }
+     // ------------------------------------------------------------------------
+     // LayoutManager ----------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Process the parent widget's resize event, and resize/reposition child
+      * widgets.
+      *
+      * @param resize resize event
+      */
+     public void onResize(final TResizeEvent resize) {
+         if (resize.getType() == TResizeEvent.Type.WIDGET) {
+             width = resize.getWidth();
+             height = resize.getHeight();
+             layoutChildren();
+         }
+     }
+     /**
+      * Add a child widget to manage.
+      *
+      * @param child the widget to manage
+      */
+     public void add(final TWidget child) {
+         children.add(child);
+         layoutChildren();
+     }
+     /**
+      * Remove a child widget from those managed by this LayoutManager.
+      *
+      * @param child the widget to remove
+      */
+     public void remove(final TWidget child) {
+         children.remove(child);
+         layoutChildren();
+     }
+     /**
+      * Reset a child widget's original/preferred size.
+      *
+      * @param child the widget to manage
+      */
+     public void resetSize(final TWidget child) {
+         // NOP
+     }
+     // ------------------------------------------------------------------------
+     // BoxLayoutManager -------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Resize/reposition child widgets based on horizontal/vertical
+      * arrangement.
+      */
+     private void layoutChildren() {
+         if (children.size() == 0) {
+             return;
+         }
+         if (vertical) {
+             int widgetHeight = Math.max(1, height / children.size());
+             int leftoverHeight = height % children.size();
+             for (int i = 0; i < children.size() - 1; i++) {
+                 TWidget child = children.get(i);
+                 child.setDimensions(child.getX(), i * widgetHeight,
+                     width, widgetHeight);
+             }
+             TWidget child = children.get(children.size() - 1);
+             child.setDimensions(child.getX(),
+                 (children.size() - 1) * widgetHeight, width,
+                 widgetHeight + leftoverHeight);
+         } else {
+             int widgetWidth = Math.max(1, width / children.size());
+             int leftoverWidth = width % children.size();
+             for (int i = 0; i < children.size() - 1; i++) {
+                 TWidget child = children.get(i);
+                 child.setDimensions(i * widgetWidth, child.getY(),
+                     widgetWidth, height);
+             }
+             TWidget child = children.get(children.size() - 1);
+             child.setDimensions((children.size() - 1) * widgetWidth,
+                 child.getY(), widgetWidth + leftoverWidth, height);
+         }
+     }
+ }
index 0000000000000000000000000000000000000000,5dbd1e8432401df45f1a45ef34e42898c717d58b..5dbd1e8432401df45f1a45ef34e42898c717d58b
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,69 +1,69 @@@
+ /*
+  * Jexer - Java Text User Interface
+  *
+  * The MIT License (MIT)
+  *
+  * 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"),
+  * to deal in the Software without restriction, including without limitation
+  * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+  * and/or sell copies of the Software, and to permit persons to whom the
+  * Software is furnished to do so, subject to the following conditions:
+  *
+  * The above copyright notice and this permission notice shall be included in
+  * all copies or substantial portions of the Software.
+  *
+  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+  * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+  * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+  * DEALINGS IN THE SOFTWARE.
+  *
+  * @author Kevin Lamonte [kevin.lamonte@gmail.com]
+  * @version 1
+  */
+ package jexer.layout;
+ import jexer.TWidget;
+ import jexer.event.TResizeEvent;
+ /**
+  * A LayoutManager provides automatic positioning and sizing of a TWidget's
+  * child TWidgets.
+  */
+ public interface LayoutManager {
+     /**
+      * Process the parent widget's resize event, and resize/reposition child
+      * widgets.
+      *
+      * @param resize resize event
+      */
+     public void onResize(final TResizeEvent resize);
+     /**
+      * Add a child widget to manage.
+      *
+      * @param child the widget to manage
+      */
+     public void add(final TWidget child);
+     /**
+      * Remove a child widget from those managed by this LayoutManager.
+      *
+      * @param child the widget to remove
+      */
+     public void remove(final TWidget child);
+     /**
+      * Reset a child widget's original/preferred size.
+      *
+      * @param child the widget to manage
+      */
+     public void resetSize(final TWidget child);
+ }
index 0000000000000000000000000000000000000000,ee2bf5aba5e5f70d4da15e6376bcd36164b26f7e..ee2bf5aba5e5f70d4da15e6376bcd36164b26f7e
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,165 +1,165 @@@
+ /*
+  * Jexer - Java Text User Interface
+  *
+  * The MIT License (MIT)
+  *
+  * 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"),
+  * to deal in the Software without restriction, including without limitation
+  * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+  * and/or sell copies of the Software, and to permit persons to whom the
+  * Software is furnished to do so, subject to the following conditions:
+  *
+  * The above copyright notice and this permission notice shall be included in
+  * all copies or substantial portions of the Software.
+  *
+  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+  * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+  * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+  * DEALINGS IN THE SOFTWARE.
+  *
+  * @author Kevin Lamonte [kevin.lamonte@gmail.com]
+  * @version 1
+  */
+ package jexer.layout;
+ import java.awt.Rectangle;
+ import java.util.HashMap;
+ import jexer.TWidget;
+ import jexer.event.TResizeEvent;
+ /**
+  * StretchLayoutManager repositions child widgets based on their coordinates
+  * when added and the current widget size.
+  */
+ public class StretchLayoutManager implements LayoutManager {
+     // ------------------------------------------------------------------------
+     // Variables --------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Current width.
+      */
+     private int width = 0;
+     /**
+      * Current height.
+      */
+     private int height = 0;
+     /**
+      * Original width.
+      */
+     private int originalWidth = 0;
+     /**
+      * Original height.
+      */
+     private int originalHeight = 0;
+     /**
+      * Map of widget to original dimensions.
+      */
+     private HashMap<TWidget, Rectangle> children = new HashMap<TWidget, Rectangle>();
+     // ------------------------------------------------------------------------
+     // Constructors -----------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Public constructor.
+      *
+      * @param width the width of the parent widget
+      * @param height the height of the parent widget
+      */
+     public StretchLayoutManager(final int width, final int height) {
+         originalWidth = width;
+         originalHeight = height;
+         this.width = width;
+         this.height = height;
+     }
+     // ------------------------------------------------------------------------
+     // LayoutManager ----------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Process the parent widget's resize event, and resize/reposition child
+      * widgets.
+      *
+      * @param resize resize event
+      */
+     public void onResize(final TResizeEvent resize) {
+         if (resize.getType() == TResizeEvent.Type.WIDGET) {
+             width = resize.getWidth();
+             height = resize.getHeight();
+             layoutChildren();
+         }
+     }
+     /**
+      * Add a child widget to manage.
+      *
+      * @param child the widget to manage
+      */
+     public void add(final TWidget child) {
+         Rectangle rect = new Rectangle(child.getX(), child.getY(),
+             child.getWidth(), child.getHeight());
+         children.put(child, rect);
+         layoutChildren();
+     }
+     /**
+      * Remove a child widget from those managed by this LayoutManager.
+      *
+      * @param child the widget to remove
+      */
+     public void remove(final TWidget child) {
+         children.remove(child);
+         layoutChildren();
+     }
+     /**
+      * Reset a child widget's original/preferred size.
+      *
+      * @param child the widget to manage
+      */
+     public void resetSize(final TWidget child) {
+         // For this layout, adding is the same as replacing.
+         add(child);
+     }
+     // ------------------------------------------------------------------------
+     // StretchLayoutManager ---------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Resize/reposition child widgets based on difference between current
+      * dimensions and the original dimensions.
+      */
+     private void layoutChildren() {
+         double widthRatio = (double) width / originalWidth;
+         if (!Double.isFinite(widthRatio)) {
+             widthRatio = 1;
+         }
+         double heightRatio = (double) height / originalHeight;
+         if (!Double.isFinite(heightRatio)) {
+             heightRatio = 1;
+         }
+         for (TWidget child: children.keySet()) {
+             Rectangle rect = children.get(child);
+             child.setDimensions((int) (rect.getX() * widthRatio),
+                 (int) (rect.getY() * heightRatio),
+                 (int) (rect.getWidth() * widthRatio),
+                 (int) (rect.getHeight() * heightRatio));
+         }
+     }
+ }
index 0000000000000000000000000000000000000000,69887ddd3d3f79c2bc36ad3e1f2563bee601f16d..69887ddd3d3f79c2bc36ad3e1f2563bee601f16d
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,33 +1,33 @@@
+ /*
+  * Jexer - Java Text User Interface
+  *
+  * The MIT License (MIT)
+  *
+  * 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"),
+  * to deal in the Software without restriction, including without limitation
+  * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+  * and/or sell copies of the Software, and to permit persons to whom the
+  * Software is furnished to do so, subject to the following conditions:
+  *
+  * The above copyright notice and this permission notice shall be included in
+  * all copies or substantial portions of the Software.
+  *
+  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+  * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+  * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+  * DEALINGS IN THE SOFTWARE.
+  *
+  * @author Kevin Lamonte [kevin.lamonte@gmail.com]
+  * @version 1
+  */
+ /**
+  * Available layout managers.
+  */
+ package jexer.layout;
index 0000000000000000000000000000000000000000,6d746df0c42ebc3f6da6ede018a33ff439233235..6d746df0c42ebc3f6da6ede018a33ff439233235
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,822 +1,822 @@@
+ /*
+  * Jexer - Java Text User Interface
+  *
+  * The MIT License (MIT)
+  *
+  * 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"),
+  * to deal in the Software without restriction, including without limitation
+  * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+  * and/or sell copies of the Software, and to permit persons to whom the
+  * Software is furnished to do so, subject to the following conditions:
+  *
+  * The above copyright notice and this permission notice shall be included in
+  * all copies or substantial portions of the Software.
+  *
+  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+  * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+  * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+  * DEALINGS IN THE SOFTWARE.
+  *
+  * @author Kevin Lamonte [kevin.lamonte@gmail.com]
+  * @version 1
+  */
+ package jexer.menu;
+ import java.util.ResourceBundle;
+ import jexer.TApplication;
+ import jexer.TKeypress;
+ import jexer.TWidget;
+ import jexer.TWindow;
+ import jexer.bits.CellAttributes;
+ import jexer.bits.GraphicsChars;
+ import jexer.bits.MnemonicString;
+ import jexer.bits.StringUtils;
+ import jexer.event.TKeypressEvent;
+ import jexer.event.TMouseEvent;
+ import static jexer.TKeypress.*;
+ /**
+  * TMenu is a top-level collection of TMenuItems.
+  */
+ public class TMenu extends TWindow {
+     /**
+      * Translated strings.
+      */
+     private static final ResourceBundle i18n = ResourceBundle.getBundle(TMenu.class.getName());
+     // ------------------------------------------------------------------------
+     // Constants --------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     // Reserved menu item IDs
+     public static final int MID_UNUSED          = -1;
+     // Tools menu
+     public static final int MID_REPAINT         = 1;
+     public static final int MID_VIEW_IMAGE      = 2;
+     public static final int MID_SCREEN_OPTIONS  = 3;
+     // File menu
+     public static final int MID_NEW             = 10;
+     public static final int MID_EXIT            = 11;
+     public static final int MID_QUIT            = MID_EXIT;
+     public static final int MID_OPEN_FILE       = 12;
+     public static final int MID_SHELL           = 13;
+     // Edit menu
+     public static final int MID_CUT             = 20;
+     public static final int MID_COPY            = 21;
+     public static final int MID_PASTE           = 22;
+     public static final int MID_CLEAR           = 23;
+     // Search menu
+     public static final int MID_FIND            = 30;
+     public static final int MID_REPLACE         = 31;
+     public static final int MID_SEARCH_AGAIN    = 32;
+     public static final int MID_GOTO_LINE       = 33;
+     // Window menu
+     public static final int MID_TILE            = 40;
+     public static final int MID_CASCADE         = 41;
+     public static final int MID_CLOSE_ALL       = 42;
+     public static final int MID_WINDOW_MOVE     = 43;
+     public static final int MID_WINDOW_ZOOM     = 44;
+     public static final int MID_WINDOW_NEXT     = 45;
+     public static final int MID_WINDOW_PREVIOUS = 46;
+     public static final int MID_WINDOW_CLOSE    = 47;
+     // Help menu
+     public static final int MID_HELP_CONTENTS           = 50;
+     public static final int MID_HELP_INDEX              = 51;
+     public static final int MID_HELP_SEARCH             = 52;
+     public static final int MID_HELP_PREVIOUS           = 53;
+     public static final int MID_HELP_HELP               = 54;
+     public static final int MID_HELP_ACTIVE_FILE        = 55;
+     public static final int MID_ABOUT                   = 56;
+     // Table menu
+     public static final int MID_TABLE_RENAME_ROW                = 60;
+     public static final int MID_TABLE_RENAME_COLUMN             = 61;
+     public static final int MID_TABLE_VIEW_ROW_LABELS           = 70;
+     public static final int MID_TABLE_VIEW_COLUMN_LABELS        = 71;
+     public static final int MID_TABLE_VIEW_HIGHLIGHT_ROW        = 72;
+     public static final int MID_TABLE_VIEW_HIGHLIGHT_COLUMN     = 73;
+     public static final int MID_TABLE_BORDER_NONE               = 80;
+     public static final int MID_TABLE_BORDER_ALL                = 81;
+     public static final int MID_TABLE_BORDER_CELL_NONE          = 82;
+     public static final int MID_TABLE_BORDER_CELL_ALL           = 83;
+     public static final int MID_TABLE_BORDER_RIGHT              = 84;
+     public static final int MID_TABLE_BORDER_LEFT               = 85;
+     public static final int MID_TABLE_BORDER_TOP                = 86;
+     public static final int MID_TABLE_BORDER_BOTTOM             = 87;
+     public static final int MID_TABLE_BORDER_DOUBLE_BOTTOM      = 88;
+     public static final int MID_TABLE_BORDER_THICK_BOTTOM       = 89;
+     public static final int MID_TABLE_DELETE_LEFT               = 100;
+     public static final int MID_TABLE_DELETE_UP                 = 101;
+     public static final int MID_TABLE_DELETE_ROW                = 102;
+     public static final int MID_TABLE_DELETE_COLUMN             = 103;
+     public static final int MID_TABLE_INSERT_LEFT               = 104;
+     public static final int MID_TABLE_INSERT_RIGHT              = 105;
+     public static final int MID_TABLE_INSERT_ABOVE              = 106;
+     public static final int MID_TABLE_INSERT_BELOW              = 107;
+     public static final int MID_TABLE_COLUMN_NARROW             = 110;
+     public static final int MID_TABLE_COLUMN_WIDEN              = 111;
+     public static final int MID_TABLE_FILE_OPEN_CSV             = 115;
+     public static final int MID_TABLE_FILE_SAVE_CSV             = 116;
+     public static final int MID_TABLE_FILE_SAVE_TEXT            = 117;
+     // ------------------------------------------------------------------------
+     // Variables --------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * If true, this is a sub-menu.  Note package private access.
+      */
+     boolean isSubMenu = false;
+     /**
+      * The X position of the menu's title.
+      */
+     private int titleX;
+     /**
+      * The shortcut and title.
+      */
+     private MnemonicString mnemonic;
+     // ------------------------------------------------------------------------
+     // Constructors -----------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Public constructor.
+      *
+      * @param parent parent application
+      * @param x column relative to parent
+      * @param y row relative to parent
+      * @param label mnemonic menu title.  Label must contain a keyboard
+      * shortcut (mnemonic), denoted by prefixing a letter with "&amp;",
+      * e.g. "&amp;File"
+      */
+     public TMenu(final TApplication parent, final int x, final int y,
+         final String label) {
+         super(parent, label, x, y, parent.getScreen().getWidth(),
+             parent.getScreen().getHeight());
+         // Setup the menu shortcut
+         mnemonic = new MnemonicString(label);
+         setTitle(mnemonic.getRawLabel());
+         assert (mnemonic.getShortcutIdx() >= 0);
+         // Recompute width and height to reflect an empty menu
+         setWidth(StringUtils.width(getTitle()) + 4);
+         setHeight(2);
+         setActive(false);
+     }
+     // ------------------------------------------------------------------------
+     // Event handlers ---------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Handle mouse button presses.
+      *
+      * @param mouse mouse button event
+      */
+     @Override
+     public void onMouseDown(final TMouseEvent mouse) {
+         this.mouse = mouse;
+         super.onMouseDown(mouse);
+         // Pass to children
+         for (TWidget widget: getChildren()) {
+             if (widget.mouseWouldHit(mouse)) {
+                 // Dispatch to this child, also activate it
+                 activate(widget);
+                 // Set x and y relative to the child's coordinates
+                 mouse.setX(mouse.getAbsoluteX() - widget.getAbsoluteX());
+                 mouse.setY(mouse.getAbsoluteY() - widget.getAbsoluteY());
+                 widget.handleEvent(mouse);
+                 return;
+             }
+         }
+     }
+     /**
+      * Handle mouse button releases.
+      *
+      * @param mouse mouse button release event
+      */
+     @Override
+     public void onMouseUp(final TMouseEvent mouse) {
+         this.mouse = mouse;
+         // Pass to children
+         for (TWidget widget: getChildren()) {
+             if (widget.mouseWouldHit(mouse)) {
+                 // Dispatch to this child, also activate it
+                 activate(widget);
+                 // Set x and y relative to the child's coordinates
+                 mouse.setX(mouse.getAbsoluteX() - widget.getAbsoluteX());
+                 mouse.setY(mouse.getAbsoluteY() - widget.getAbsoluteY());
+                 widget.handleEvent(mouse);
+                 return;
+             }
+         }
+     }
+     /**
+      * Handle mouse movements.
+      *
+      * @param mouse mouse motion event
+      */
+     @Override
+     public void onMouseMotion(final TMouseEvent mouse) {
+         this.mouse = mouse;
+         // See if we should activate a different menu item
+         for (TWidget widget: getChildren()) {
+             if ((mouse.isMouse1())
+                 && (widget.mouseWouldHit(mouse))
+             ) {
+                 // Activate this menu item
+                 activate(widget);
+                 if (widget instanceof TSubMenu) {
+                     ((TSubMenu) widget).dispatch();
+                 }
+                 return;
+             }
+         }
+     }
+     /**
+      * Handle keystrokes.
+      *
+      * @param keypress keystroke event
+      */
+     @Override
+     public void onKeypress(final TKeypressEvent keypress) {
+         /*
+         System.err.printf("keypress: %s active child: %s\n", keypress,
+             getActiveChild());
+         */
+         if (getActiveChild() != this) {
+             if (getActiveChild() instanceof TMenu) {
+                 getActiveChild().onKeypress(keypress);
+                 return;
+             }
+             if (getActiveChild() instanceof TSubMenu) {
+                 TSubMenu subMenu = (TSubMenu) getActiveChild();
+                 if (subMenu.menu.isActive()) {
+                     subMenu.onKeypress(keypress);
+                     return;
+                 }
+             }
+         }
+         if (keypress.equals(kbEsc)) {
+             getApplication().closeMenu();
+             return;
+         }
+         if (keypress.equals(kbDown)) {
+             switchWidget(true);
+             return;
+         }
+         if (keypress.equals(kbUp)) {
+             switchWidget(false);
+             return;
+         }
+         if (keypress.equals(kbRight)) {
+             getApplication().switchMenu(true);
+             return;
+         }
+         if (keypress.equals(kbLeft)) {
+             if (isSubMenu) {
+                 getApplication().closeSubMenu();
+             } else {
+                 getApplication().switchMenu(false);
+             }
+             return;
+         }
+         // Switch to a menuItem if it has an mnemonic
+         if (!keypress.getKey().isFnKey()
+             && !keypress.getKey().isAlt()
+             && !keypress.getKey().isCtrl()) {
+             // System.err.println("Checking children for mnemonic...");
+             for (TWidget widget: getChildren()) {
+                 TMenuItem item = (TMenuItem) widget;
+                 if ((item.isEnabled() == true)
+                     && (item.getMnemonic() != null)
+                     && (Character.toLowerCase(item.getMnemonic().getShortcut())
+                         == Character.toLowerCase(keypress.getKey().getChar()))
+                 ) {
+                     // System.err.println("activate: " + item);
+                     // Send an enter keystroke to it
+                     activate(item);
+                     item.handleEvent(new TKeypressEvent(kbEnter));
+                     return;
+                 }
+             }
+         }
+         // Dispatch the keypress to an active widget
+         for (TWidget widget: getChildren()) {
+             if (widget.isActive()) {
+                 widget.handleEvent(keypress);
+                 return;
+             }
+         }
+     }
+     // ------------------------------------------------------------------------
+     // TWindow ----------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Draw a top-level menu with title and menu items.
+      */
+     @Override
+     public void draw() {
+         CellAttributes background = getTheme().getColor("tmenu");
+         assert (isAbsoluteActive());
+         // Fill in the interior background
+         for (int i = 0; i < getHeight(); i++) {
+             hLineXY(0, i, getWidth(), ' ', background);
+         }
+         // Draw the box
+         char cTopLeft;
+         char cTopRight;
+         char cBottomLeft;
+         char cBottomRight;
+         char cHSide;
+         cTopLeft = GraphicsChars.ULCORNER;
+         cTopRight = GraphicsChars.URCORNER;
+         cBottomLeft = GraphicsChars.LLCORNER;
+         cBottomRight = GraphicsChars.LRCORNER;
+         cHSide = GraphicsChars.SINGLE_BAR;
+         // Place the corner characters
+         putCharXY(1, 0, cTopLeft, background);
+         putCharXY(getWidth() - 2, 0, cTopRight, background);
+         putCharXY(1, getHeight() - 1, cBottomLeft, background);
+         putCharXY(getWidth() - 2, getHeight() - 1, cBottomRight, background);
+         // Draw the box lines
+         hLineXY(1 + 1, 0, getWidth() - 4, cHSide, background);
+         hLineXY(1 + 1, getHeight() - 1, getWidth() - 4, cHSide, background);
+         // Draw a shadow
+         drawBoxShadow(0, 0, getWidth(), getHeight());
+     }
+     // ------------------------------------------------------------------------
+     // TMenu ------------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Set the menu title X position.
+      *
+      * @param titleX the position
+      */
+     public void setTitleX(final int titleX) {
+         this.titleX = titleX;
+     }
+     /**
+      * Get the menu title X position.
+      *
+      * @return the position
+      */
+     public int getTitleX() {
+         return titleX;
+     }
+     /**
+      * Get the mnemonic string.
+      *
+      * @return the full mnemonic string
+      */
+     public MnemonicString getMnemonic() {
+         return mnemonic;
+     }
+     /**
+      * Convenience function to add a menu item.
+      *
+      * @param id menu item ID.  Must be greater than 1024.
+      * @param label menu item label
+      * @return the new menu item
+      */
+     public TMenuItem addItem(final int id, final String label) {
+         return addItemInternal(id, label, null);
+     }
+     /**
+      * Convenience function to add a menu item.
+      *
+      * @param id menu item ID.  Must be greater than 1024.
+      * @param label menu item label
+      * @param enabled default state for enabled
+      * @return the new menu item
+      */
+     public TMenuItem addItem(final int id, final String label,
+         final boolean enabled) {
+         assert (id >= 1024);
+         return addItemInternal(id, label, null, enabled);
+     }
+     /**
+      * Convenience function to add a custom menu item.
+      *
+      * @param id menu item ID.  Must be greater than 1024.
+      * @param label menu item label
+      * @param key global keyboard accelerator
+      * @return the new menu item
+      */
+     public TMenuItem addItem(final int id, final String label,
+         final TKeypress key) {
+         assert (id >= 1024);
+         return addItemInternal(id, label, key);
+     }
+     /**
+      * Convenience function to add a custom menu item.
+      *
+      * @param id menu item ID.  Must be greater than 1024.
+      * @param label menu item label
+      * @param key global keyboard accelerator
+      * @param enabled default state for enabled
+      * @return the new menu item
+      */
+     public TMenuItem addItem(final int id, final String label,
+         final TKeypress key, final boolean enabled) {
+         TMenuItem item = addItem(id, label, key);
+         item.setEnabled(enabled);
+         return item;
+     }
+     /**
+      * Convenience function to add a custom menu item.
+      *
+      * @param id menu item ID.  Must be greater than 1024.
+      * @param label menu item label
+      * @param key global keyboard accelerator
+      * @return the new menu item
+      */
+     private TMenuItem addItemInternal(final int id, final String label,
+         final TKeypress key) {
+         return addItemInternal(id, label, key, true);
+     }
+     /**
+      * Convenience function to add a custom menu item.
+      *
+      * @param id menu item ID.  Must be greater than 1024.
+      * @param label menu item label
+      * @param key global keyboard accelerator
+      * @param enabled default state for enabled
+      * @return the new menu item
+      */
+     private TMenuItem addItemInternal(final int id, final String label,
+         final TKeypress key, final boolean enabled) {
+         int newY = getChildren().size() + 1;
+         assert (newY < getHeight());
+         TMenuItem menuItem = new TMenuItem(this, id, 1, newY, label);
+         menuItem.setKey(key);
+         menuItem.setEnabled(enabled);
+         setHeight(getHeight() + 1);
+         if (menuItem.getWidth() + 2 > getWidth()) {
+             setWidth(menuItem.getWidth() + 2);
+         }
+         for (TWidget widget: getChildren()) {
+             widget.setWidth(getWidth() - 2);
+         }
+         getApplication().addMenuItem(menuItem);
+         getApplication().recomputeMenuX();
+         activate(0);
+         return menuItem;
+     }
+     /**
+      * Convenience function to add one of the default menu items.
+      *
+      * @param id menu item ID.  Must be between 0 (inclusive) and 1023
+      * (inclusive).
+      * @return the new menu item
+      */
+     public TMenuItem addDefaultItem(final int id) {
+         return addDefaultItem(id, true);
+     }
+     /**
+      * Convenience function to add one of the default menu items.
+      *
+      * @param id menu item ID.  Must be between 0 (inclusive) and 1023
+      * (inclusive).
+      * @param enabled default state for enabled
+      * @return the new menu item
+      */
+     public TMenuItem addDefaultItem(final int id, final boolean enabled) {
+         assert (id >= 0);
+         assert (id < 1024);
+         String label;
+         TKeypress key = null;
+         boolean checkable = false;
+         boolean checked = false;
+         switch (id) {
+         case MID_REPAINT:
+             label = i18n.getString("menuRepaintDesktop");
+             break;
+         case MID_VIEW_IMAGE:
+             label = i18n.getString("menuViewImage");
+             break;
+         case MID_SCREEN_OPTIONS:
+             label = i18n.getString("menuScreenOptions");
+             break;
+         case MID_NEW:
+             label = i18n.getString("menuNew");
+             break;
+         case MID_EXIT:
+             label = i18n.getString("menuExit");
+             key = kbAltX;
+             break;
+         case MID_SHELL:
+             label = i18n.getString("menuShell");
+             break;
+         case MID_OPEN_FILE:
+             label = i18n.getString("menuOpen");
+             key = kbF3;
+             break;
+         case MID_CUT:
+             label = i18n.getString("menuCut");
+             key = kbCtrlX;
+             break;
+         case MID_COPY:
+             label = i18n.getString("menuCopy");
+             key = kbCtrlC;
+             break;
+         case MID_PASTE:
+             label = i18n.getString("menuPaste");
+             key = kbCtrlV;
+             break;
+         case MID_CLEAR:
+             label = i18n.getString("menuClear");
+             // key = kbDel;
+             break;
+         case MID_FIND:
+             label = i18n.getString("menuFind");
+             break;
+         case MID_REPLACE:
+             label = i18n.getString("menuReplace");
+             break;
+         case MID_SEARCH_AGAIN:
+             label = i18n.getString("menuSearchAgain");
+             key = kbCtrlL;
+             break;
+         case MID_GOTO_LINE:
+             label = i18n.getString("menuGotoLine");
+             break;
+         case MID_TILE:
+             label = i18n.getString("menuWindowTile");
+             break;
+         case MID_CASCADE:
+             label = i18n.getString("menuWindowCascade");
+             break;
+         case MID_CLOSE_ALL:
+             label = i18n.getString("menuWindowCloseAll");
+             break;
+         case MID_WINDOW_MOVE:
+             label = i18n.getString("menuWindowMove");
+             key = kbCtrlF5;
+             break;
+         case MID_WINDOW_ZOOM:
+             label = i18n.getString("menuWindowZoom");
+             key = kbF5;
+             break;
+         case MID_WINDOW_NEXT:
+             label = i18n.getString("menuWindowNext");
+             key = kbF6;
+             break;
+         case MID_WINDOW_PREVIOUS:
+             label = i18n.getString("menuWindowPrevious");
+             key = kbShiftF6;
+             break;
+         case MID_WINDOW_CLOSE:
+             label = i18n.getString("menuWindowClose");
+             key = kbCtrlW;
+             break;
+         case MID_HELP_CONTENTS:
+             label = i18n.getString("menuHelpContents");
+             break;
+         case MID_HELP_INDEX:
+             label = i18n.getString("menuHelpIndex");
+             key = kbShiftF1;
+             break;
+         case MID_HELP_SEARCH:
+             label = i18n.getString("menuHelpSearch");
+             key = kbCtrlF1;
+             break;
+         case MID_HELP_PREVIOUS:
+             label = i18n.getString("menuHelpPrevious");
+             key = kbAltF1;
+             break;
+         case MID_HELP_HELP:
+             label = i18n.getString("menuHelpHelp");
+             break;
+         case MID_HELP_ACTIVE_FILE:
+             label = i18n.getString("menuHelpActive");
+             break;
+         case MID_ABOUT:
+             label = i18n.getString("menuHelpAbout");
+             break;
+         case MID_TABLE_RENAME_COLUMN:
+             label = i18n.getString("menuTableRenameColumn");
+             break;
+         case MID_TABLE_RENAME_ROW:
+             label = i18n.getString("menuTableRenameRow");
+             break;
+         case MID_TABLE_VIEW_ROW_LABELS:
+             label = i18n.getString("menuTableViewRowLabels");
+             checkable = true;
+             checked = true;
+             break;
+         case MID_TABLE_VIEW_COLUMN_LABELS:
+             label = i18n.getString("menuTableViewColumnLabels");
+             checkable = true;
+             checked = true;
+             break;
+         case MID_TABLE_VIEW_HIGHLIGHT_ROW:
+             label = i18n.getString("menuTableViewHighlightRow");
+             checkable = true;
+             checked = true;
+             break;
+         case MID_TABLE_VIEW_HIGHLIGHT_COLUMN:
+             label = i18n.getString("menuTableViewHighlightColumn");
+             checkable = true;
+             checked = true;
+             break;
+         case MID_TABLE_BORDER_NONE:
+             label = i18n.getString("menuTableBorderNone");
+             break;
+         case MID_TABLE_BORDER_ALL:
+             label = i18n.getString("menuTableBorderAll");
+             break;
+         case MID_TABLE_BORDER_CELL_NONE:
+             label = i18n.getString("menuTableBorderCellNone");
+             break;
+         case MID_TABLE_BORDER_CELL_ALL:
+             label = i18n.getString("menuTableBorderCellAll");
+             break;
+         case MID_TABLE_BORDER_RIGHT:
+             label = i18n.getString("menuTableBorderRight");
+             break;
+         case MID_TABLE_BORDER_LEFT:
+             label = i18n.getString("menuTableBorderLeft");
+             break;
+         case MID_TABLE_BORDER_TOP:
+             label = i18n.getString("menuTableBorderTop");
+             break;
+         case MID_TABLE_BORDER_BOTTOM:
+             label = i18n.getString("menuTableBorderBottom");
+             break;
+         case MID_TABLE_BORDER_DOUBLE_BOTTOM:
+             label = i18n.getString("menuTableBorderDoubleBottom");
+             break;
+         case MID_TABLE_BORDER_THICK_BOTTOM:
+             label = i18n.getString("menuTableBorderThickBottom");
+             break;
+         case MID_TABLE_DELETE_LEFT:
+             label = i18n.getString("menuTableDeleteLeft");
+             break;
+         case MID_TABLE_DELETE_UP:
+             label = i18n.getString("menuTableDeleteUp");
+             break;
+         case MID_TABLE_DELETE_ROW:
+             label = i18n.getString("menuTableDeleteRow");
+             break;
+         case MID_TABLE_DELETE_COLUMN:
+             label = i18n.getString("menuTableDeleteColumn");
+             break;
+         case MID_TABLE_INSERT_LEFT:
+             label = i18n.getString("menuTableInsertLeft");
+             break;
+         case MID_TABLE_INSERT_RIGHT:
+             label = i18n.getString("menuTableInsertRight");
+             break;
+         case MID_TABLE_INSERT_ABOVE:
+             label = i18n.getString("menuTableInsertAbove");
+             break;
+         case MID_TABLE_INSERT_BELOW:
+             label = i18n.getString("menuTableInsertBelow");
+             break;
+         case MID_TABLE_COLUMN_NARROW:
+             label = i18n.getString("menuTableColumnNarrow");
+             key = kbShiftLeft;
+             break;
+         case MID_TABLE_COLUMN_WIDEN:
+             label = i18n.getString("menuTableColumnWiden");
+             key = kbShiftRight;
+             break;
+         case MID_TABLE_FILE_OPEN_CSV:
+             label = i18n.getString("menuTableFileOpenCsv");
+             break;
+         case MID_TABLE_FILE_SAVE_CSV:
+             label = i18n.getString("menuTableFileSaveCsv");
+             break;
+         case MID_TABLE_FILE_SAVE_TEXT:
+             label = i18n.getString("menuTableFileSaveText");
+             break;
+         default:
+             throw new IllegalArgumentException("Invalid menu ID: " + id);
+         }
+         TMenuItem item = addItemInternal(id, label, key, enabled);
+         item.setCheckable(checkable);
+         return item;
+     }
+     /**
+      * Convenience function to add a menu separator.
+      */
+     public void addSeparator() {
+         int newY = getChildren().size() + 1;
+         assert (newY < getHeight());
+         // We just have to construct it, don't need to hang onto what it
+         // makes.
+         new TMenuSeparator(this, 1, newY);
+         setHeight(getHeight() + 1);
+     }
+     /**
+      * Convenience function to add a sub-menu.
+      *
+      * @param title menu title.  Title must contain a keyboard shortcut,
+      * denoted by prefixing a letter with "&amp;", e.g. "&amp;File"
+      * @return the new sub-menu
+      */
+     public TSubMenu addSubMenu(final String title) {
+         int newY = getChildren().size() + 1;
+         assert (newY < getHeight());
+         TSubMenu subMenu = new TSubMenu(this, title, 1, newY);
+         setHeight(getHeight() + 1);
+         if (subMenu.getWidth() + 2 > getWidth()) {
+             setWidth(subMenu.getWidth() + 2);
+         }
+         for (TWidget widget: getChildren()) {
+             widget.setWidth(getWidth() - 2);
+         }
+         getApplication().recomputeMenuX();
+         activate(0);
+         subMenu.menu.setX(getX() + getWidth() - 2);
+         return subMenu;
+     }
+ }
index 0000000000000000000000000000000000000000,4a0f8e6f6fef8b301b7467be871263b5858639cc..4a0f8e6f6fef8b301b7467be871263b5858639cc
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,62 +1,62 @@@
+ menuNew=&New
+ menuExit=E&xit
+ menuShell=O&S Shell
+ menuOpen=&Open
+ menuCut=Cu&t
+ menuCopy=&Copy
+ menuPaste=&Paste
+ menuClear=C&lear
+ menuFind=&Find...
+ menuReplace=&Replace...
+ menuSearchAgain=&Search again
+ menuGotoLine=&Go to line number...
+ menuWindowTile=&Tile
+ menuWindowCascade=C&ascade
+ menuWindowCloseAll=Cl&ose All
+ menuWindowMove=&Size/Move
+ menuWindowZoom=&Zoom
+ menuWindowNext=&Next
+ menuWindowPrevious=&Previous
+ menuWindowClose=&Close
+ menuHelpContents=&Contents
+ menuHelpIndex=&Index
+ menuHelpSearch=&Topic search
+ menuHelpPrevious=&Previous topic
+ menuHelpHelp=&Help on help
+ menuHelpActive=Active &file...
+ menuHelpAbout=&About...
+ menuTableRenameRow=Rename &Row
+ menuTableRenameColumn=Rename C&olumn
+ menuTableViewRowLabels=&Row Labels
+ menuTableViewColumnLabels=&Column Labels
+ menuTableViewHighlightRow=Highlight Selected R&ow
+ menuTableViewHighlightColumn=Highlight Selected Co&lumn
+ menuTableBorderNone=N&one (Entire Table)
+ menuTableBorderAll=&All (Entire Table)
+ menuTableBorderCellNone=&None (Selected Cell)
+ menuTableBorderCellAll=All (&Selected Cell)
+ menuTableBorderRight=&Right
+ menuTableBorderLeft=&Left
+ menuTableBorderTop=&Top
+ menuTableBorderBottom=&Bottom
+ menuTableBorderDoubleBottom=Bottom (&Double)
+ menuTableBorderThickBottom=Bottom (T&hick)
+ menuTableDeleteLeft=Cell (Shift &Left)
+ menuTableDeleteUp=Cell (Shift &Up)
+ menuTableDeleteRow=Entire &Row
+ menuTableDeleteColumn=Entire &Column
+ menuTableInsertLeft=Column &Left
+ menuTableInsertRight=Column &Right
+ menuTableInsertAbove=Row &Above
+ menuTableInsertBelow=Row &Below
+ menuTableColumnNarrow=&Narrow
+ menuTableColumnWiden=&Widen
+ menuTableFileOpenCsv=Open &CSV...
+ menuTableFileSaveCsv=Save As C&SV...
+ menuTableFileSaveText=Save As &Text...
+ menuRepaintDesktop=&Repaint desktop
+ menuViewImage=&Open image...
+ menuScreenOptions=&Screen options...
index 0000000000000000000000000000000000000000,d9dfc2ac5482b64123713f45e8b58f7f9abf3e3d..d9dfc2ac5482b64123713f45e8b58f7f9abf3e3d
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,339 +1,339 @@@
+ /*
+  * Jexer - Java Text User Interface
+  *
+  * The MIT License (MIT)
+  *
+  * 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"),
+  * to deal in the Software without restriction, including without limitation
+  * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+  * and/or sell copies of the Software, and to permit persons to whom the
+  * Software is furnished to do so, subject to the following conditions:
+  *
+  * The above copyright notice and this permission notice shall be included in
+  * all copies or substantial portions of the Software.
+  *
+  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+  * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+  * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+  * DEALINGS IN THE SOFTWARE.
+  *
+  * @author Kevin Lamonte [kevin.lamonte@gmail.com]
+  * @version 1
+  */
+ package jexer.menu;
+ import jexer.TKeypress;
+ import jexer.TWidget;
+ import jexer.bits.CellAttributes;
+ import jexer.bits.GraphicsChars;
+ import jexer.bits.MnemonicString;
+ import jexer.bits.StringUtils;
+ import jexer.event.TKeypressEvent;
+ import jexer.event.TMouseEvent;
+ import jexer.event.TMenuEvent;
+ import static jexer.TKeypress.*;
+ /**
+  * TMenuItem implements a menu item.
+  */
+ public class TMenuItem extends TWidget {
+     // ------------------------------------------------------------------------
+     // Variables --------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Label for this menu item.
+      */
+     private String label;
+     /**
+      * Menu ID.  IDs less than 1024 are reserved for common system
+      * functions.  Existing ones are defined in TMenu, i.e. TMenu.MID_EXIT.
+      */
+     private int id = TMenu.MID_UNUSED;
+     /**
+      * When true, this item can be checked or unchecked.
+      */
+     private boolean checkable = false;
+     /**
+      * When true, this item is checked.
+      */
+     private boolean checked = false;
+     /**
+      * Global shortcut key.
+      */
+     private TKeypress key;
+     /**
+      * The title string.  Use '&' to specify a mnemonic, i.e. "&File" will
+      * highlight the 'F' and allow 'f' or 'F' to select it.
+      */
+     private MnemonicString mnemonic;
+     // ------------------------------------------------------------------------
+     // Constructors -----------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Package private constructor.
+      *
+      * @param parent parent widget
+      * @param id menu id
+      * @param x column relative to parent
+      * @param y row relative to parent
+      * @param label menu item title
+      */
+     TMenuItem(final TMenu parent, final int id, final int x, final int y,
+         final String label) {
+         // Set parent and window
+         super(parent);
+         mnemonic = new MnemonicString(label);
+         setX(x);
+         setY(y);
+         setHeight(1);
+         this.label = mnemonic.getRawLabel();
+         setWidth(StringUtils.width(label) + 4);
+         this.id = id;
+         // Default state for some known menu items
+         switch (id) {
+         case TMenu.MID_CUT:
+             setEnabled(false);
+             break;
+         case TMenu.MID_COPY:
+             setEnabled(false);
+             break;
+         case TMenu.MID_PASTE:
+             setEnabled(false);
+             break;
+         case TMenu.MID_CLEAR:
+             setEnabled(false);
+             break;
+         case TMenu.MID_TILE:
+             break;
+         case TMenu.MID_CASCADE:
+             break;
+         case TMenu.MID_CLOSE_ALL:
+             break;
+         case TMenu.MID_WINDOW_MOVE:
+             break;
+         case TMenu.MID_WINDOW_ZOOM:
+             break;
+         case TMenu.MID_WINDOW_NEXT:
+             break;
+         case TMenu.MID_WINDOW_PREVIOUS:
+             break;
+         case TMenu.MID_WINDOW_CLOSE:
+             break;
+         default:
+             break;
+         }
+     }
+     // ------------------------------------------------------------------------
+     // Event handlers ---------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Returns true if the mouse is currently on the menu item.
+      *
+      * @param mouse mouse event
+      * @return if true then the mouse is currently on this item
+      */
+     private boolean mouseOnMenuItem(final TMouseEvent mouse) {
+         if ((mouse.getY() == 0)
+             && (mouse.getX() >= 0)
+             && (mouse.getX() < getWidth())
+         ) {
+             return true;
+         }
+         return false;
+     }
+     /**
+      * Handle mouse button releases.
+      *
+      * @param mouse mouse button release event
+      */
+     @Override
+     public void onMouseUp(final TMouseEvent mouse) {
+         if ((mouseOnMenuItem(mouse)) && (mouse.isMouse1())) {
+             dispatch();
+             return;
+         }
+     }
+     /**
+      * Handle keystrokes.
+      *
+      * @param keypress keystroke event
+      */
+     @Override
+     public void onKeypress(final TKeypressEvent keypress) {
+         if (keypress.equals(kbEnter)) {
+             dispatch();
+             return;
+         }
+         // Pass to parent for the things we don't care about.
+         super.onKeypress(keypress);
+     }
+     // ------------------------------------------------------------------------
+     // TWidget ----------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Draw a menu item with label.
+      */
+     @Override
+     public void draw() {
+         CellAttributes background = getTheme().getColor("tmenu");
+         CellAttributes menuColor;
+         CellAttributes menuMnemonicColor;
+         if (isAbsoluteActive()) {
+             menuColor = getTheme().getColor("tmenu.highlighted");
+             menuMnemonicColor = getTheme().getColor("tmenu.mnemonic.highlighted");
+         } else {
+             if (isEnabled()) {
+                 menuColor = getTheme().getColor("tmenu");
+                 menuMnemonicColor = getTheme().getColor("tmenu.mnemonic");
+             } else {
+                 menuColor = getTheme().getColor("tmenu.disabled");
+                 menuMnemonicColor = getTheme().getColor("tmenu.disabled");
+             }
+         }
+         char cVSide = GraphicsChars.WINDOW_SIDE;
+         vLineXY(0, 0, 1, cVSide, background);
+         vLineXY(getWidth() - 1, 0, 1, cVSide, background);
+         hLineXY(1, 0, getWidth() - 2, ' ', menuColor);
+         putStringXY(2, 0, mnemonic.getRawLabel(), menuColor);
+         if (key != null) {
+             String keyLabel = key.toString();
+             putStringXY((getWidth() - StringUtils.width(keyLabel) - 2), 0,
+                 keyLabel, menuColor);
+         }
+         if (mnemonic.getScreenShortcutIdx() >= 0) {
+             putCharXY(2 + mnemonic.getScreenShortcutIdx(), 0,
+                 mnemonic.getShortcut(), menuMnemonicColor);
+         }
+         if (checked) {
+             assert (checkable);
+             putCharXY(1, 0, GraphicsChars.CHECK, menuColor);
+         }
+     }
+     // ------------------------------------------------------------------------
+     // TMenuItem --------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Get the menu item ID.
+      *
+      * @return the id
+      */
+     public final int getId() {
+         return id;
+     }
+     /**
+      * Set checkable flag.
+      *
+      * @param checkable if true, this menu item can be checked/unchecked
+      */
+     public final void setCheckable(final boolean checkable) {
+         this.checkable = checkable;
+     }
+     /**
+      * Get checkable flag.
+      *
+      * @return true if this menu item is both checkable and checked
+      */
+     public final boolean getChecked() {
+         return ((checkable == true) && (checked == true));
+     }
+     /**
+      * Set checked flag.  Note that setting checked on an item checkable will
+      * do nothing.
+      *
+      * @param checked if true, and if this menu item is checkable, then
+      * getChecked() will return true
+      */
+     public final void setChecked(final boolean checked) {
+         if (checkable) {
+             this.checked = checked;
+         } else {
+             this.checked = false;
+         }
+     }
+     /**
+      * Get the mnemonic string for this menu item.
+      *
+      * @return mnemonic string
+      */
+     public final MnemonicString getMnemonic() {
+         return mnemonic;
+     }
+     /**
+      * Get a global accelerator key for this menu item.
+      *
+      * @return global keyboard accelerator, or null if no key is associated
+      * with this item
+      */
+     public final TKeypress getKey() {
+         return key;
+     }
+     /**
+      * Set a global accelerator key for this menu item.
+      *
+      * @param key global keyboard accelerator
+      */
+     public final void setKey(final TKeypress key) {
+         this.key = key;
+         if (key != null) {
+             int newWidth = (StringUtils.width(label) + 4 +
+                 StringUtils.width(key.toString()) + 2);
+             if (newWidth > getWidth()) {
+                 setWidth(newWidth);
+             }
+         }
+     }
+     /**
+      * Dispatch event(s) due to selection or click.
+      */
+     public void dispatch() {
+         assert (isEnabled());
+         getApplication().postMenuEvent(new TMenuEvent(id));
+         if (checkable) {
+             checked = !checked;
+         }
+     }
+ }
index 0000000000000000000000000000000000000000,0528e5d73207cd8d8cd5abcca72c167b6c757149..0528e5d73207cd8d8cd5abcca72c167b6c757149
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,73 +1,73 @@@
+ /*
+  * Jexer - Java Text User Interface
+  *
+  * The MIT License (MIT)
+  *
+  * 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"),
+  * to deal in the Software without restriction, including without limitation
+  * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+  * and/or sell copies of the Software, and to permit persons to whom the
+  * Software is furnished to do so, subject to the following conditions:
+  *
+  * The above copyright notice and this permission notice shall be included in
+  * all copies or substantial portions of the Software.
+  *
+  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+  * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+  * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+  * DEALINGS IN THE SOFTWARE.
+  *
+  * @author Kevin Lamonte [kevin.lamonte@gmail.com]
+  * @version 1
+  */
+ package jexer.menu;
+ import jexer.bits.CellAttributes;
+ import jexer.bits.GraphicsChars;
+ /**
+  * TMenuSeparator is a special case menu item.
+  */
+ public class TMenuSeparator extends TMenuItem {
+     // ------------------------------------------------------------------------
+     // Constructors -----------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Package private constructor.
+      *
+      * @param parent parent widget
+      * @param x column relative to parent
+      * @param y row relative to parent
+      */
+     TMenuSeparator(final TMenu parent, final int x, final int y) {
+         super(parent, TMenu.MID_UNUSED, x, y, "");
+         setEnabled(false);
+         setActive(false);
+         setWidth(parent.getWidth() - 2);
+     }
+     // ------------------------------------------------------------------------
+     // TMenuItem --------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Draw a menu separator.
+      */
+     @Override
+     public void draw() {
+         CellAttributes background = getTheme().getColor("tmenu");
+         putCharXY(0, 0, GraphicsChars.CP437[0xC3], background);
+         putCharXY(getWidth() - 1, 0, GraphicsChars.CP437[0xB4], background);
+         hLineXY(1, 0, getWidth() - 2, GraphicsChars.SINGLE_BAR, background);
+     }
+ }
index 0000000000000000000000000000000000000000,e285c5ab6d78c485f67ed6e2597c4c17e31af189..e285c5ab6d78c485f67ed6e2597c4c17e31af189
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,267 +1,267 @@@
+ /*
+  * Jexer - Java Text User Interface
+  *
+  * The MIT License (MIT)
+  *
+  * 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"),
+  * to deal in the Software without restriction, including without limitation
+  * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+  * and/or sell copies of the Software, and to permit persons to whom the
+  * Software is furnished to do so, subject to the following conditions:
+  *
+  * The above copyright notice and this permission notice shall be included in
+  * all copies or substantial portions of the Software.
+  *
+  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+  * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+  * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+  * DEALINGS IN THE SOFTWARE.
+  *
+  * @author Kevin Lamonte [kevin.lamonte@gmail.com]
+  * @version 1
+  */
+ package jexer.menu;
+ import jexer.TKeypress;
+ import jexer.TWidget;
+ import jexer.bits.CellAttributes;
+ import jexer.bits.GraphicsChars;
+ import jexer.event.TKeypressEvent;
+ import static jexer.TKeypress.*;
+ /**
+  * TSubMenu is a special case menu item that wraps another TMenu.
+  */
+ public class TSubMenu extends TMenuItem {
+     // ------------------------------------------------------------------------
+     // Variables --------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * The menu window.  Note package private access.
+      */
+     TMenu menu;
+     // ------------------------------------------------------------------------
+     // Constructors -----------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Package private constructor.
+      *
+      * @param parent parent widget
+      * @param title menu title.  Title must contain a keyboard shortcut,
+      * denoted by prefixing a letter with "&amp;", e.g. "&amp;File"
+      * @param x column relative to parent
+      * @param y row relative to parent
+      */
+     TSubMenu(final TMenu parent, final String title, final int x, final int y) {
+         super(parent, TMenu.MID_UNUSED, x, y, title);
+         setActive(false);
+         setEnabled(true);
+         this.menu = new TMenu(parent.getApplication(), x, getAbsoluteY() - 1,
+             title);
+         setWidth(menu.getWidth() + 2);
+         this.menu.isSubMenu = true;
+     }
+     // ------------------------------------------------------------------------
+     // Event handlers ---------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Handle keystrokes.
+      *
+      * @param keypress keystroke event
+      */
+     @Override
+     public void onKeypress(final TKeypressEvent keypress) {
+         // Open me if they hit my mnemonic.
+         if (!keypress.getKey().isFnKey()
+             && !keypress.getKey().isAlt()
+             && !keypress.getKey().isCtrl()
+             && (getMnemonic() != null)
+             && (Character.toLowerCase(getMnemonic().getShortcut())
+                 == Character.toLowerCase(keypress.getKey().getChar()))
+         ) {
+             dispatch();
+             return;
+         }
+         if (menu.isActive()) {
+             menu.onKeypress(keypress);
+             return;
+         }
+         if (keypress.equals(kbEnter)) {
+             dispatch();
+             return;
+         }
+         if (keypress.equals(kbRight)) {
+             dispatch();
+             return;
+         }
+         if (keypress.equals(kbDown)) {
+             getParent().switchWidget(true);
+             return;
+         }
+         if (keypress.equals(kbUp)) {
+             getParent().switchWidget(false);
+             return;
+         }
+         if (keypress.equals(kbLeft)) {
+             TMenu parentMenu = (TMenu) getParent();
+             if (parentMenu.isSubMenu) {
+                 getApplication().closeSubMenu();
+             } else {
+                 getApplication().switchMenu(false);
+             }
+             return;
+         }
+         if (keypress.equals(kbEsc)) {
+             getApplication().closeMenu();
+             return;
+         }
+     }
+     // ------------------------------------------------------------------------
+     // TMenuItem --------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Draw the menu title.
+      */
+     @Override
+     public void draw() {
+         super.draw();
+         CellAttributes menuColor;
+         if (isAbsoluteActive()) {
+             menuColor = getTheme().getColor("tmenu.highlighted");
+         } else {
+             if (isEnabled()) {
+                 menuColor = getTheme().getColor("tmenu");
+             } else {
+                 menuColor = getTheme().getColor("tmenu.disabled");
+             }
+         }
+         // Add the arrow
+         putCharXY(getWidth() - 2, 0, GraphicsChars.CP437[0x10], menuColor);
+     }
+     /**
+      * Override dispatch() to do nothing.
+      */
+     @Override
+     public void dispatch() {
+         assert (isEnabled());
+         if (isAbsoluteActive()) {
+             if (!menu.isActive()) {
+                 getApplication().addSubMenu(menu);
+                 menu.setActive(true);
+             }
+         }
+     }
+     /**
+      * Returns my active widget.
+      *
+      * @return widget that is active, or this if no children
+      */
+     @Override
+     public TWidget getActiveChild() {
+         if (menu.isActive()) {
+             return menu;
+         }
+         // Menu not active, return me
+         return this;
+     }
+     // ------------------------------------------------------------------------
+     // TSubMenu ---------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Convenience function to add a custom menu item.
+      *
+      * @param id menu item ID.  Must be greater than 1024.
+      * @param label menu item label
+      * @param key global keyboard accelerator
+      * @return the new menu item
+      */
+     public TMenuItem addItem(final int id, final String label,
+         final TKeypress key) {
+         return menu.addItem(id, label, key);
+     }
+     /**
+      * Convenience function to add a menu item.
+      *
+      * @param id menu item ID.  Must be greater than 1024.
+      * @param label menu item label
+      * @return the new menu item
+      */
+     public TMenuItem addItem(final int id, final String label) {
+         return menu.addItem(id, label);
+     }
+     /**
+      * Convenience function to add one of the default menu items.
+      *
+      * @param id menu item ID.  Must be between 0 (inclusive) and 1023
+      * (inclusive).
+      * @return the new menu item
+      */
+     public TMenuItem addDefaultItem(final int id) {
+         return menu.addDefaultItem(id);
+     }
+     /**
+      * Convenience function to add one of the default menu items.
+      *
+      * @param id menu item ID.  Must be between 0 (inclusive) and 1023
+      * (inclusive).
+      * @param enabled default state for enabled
+      * @return the new menu item
+      */
+     public TMenuItem addDefaultItem(final int id, final boolean enabled) {
+         return menu.addDefaultItem(id, enabled);
+     }
+     /**
+      * Convenience function to add a menu separator.
+      */
+     public void addSeparator() {
+         menu.addSeparator();
+     }
+     /**
+      * Convenience function to add a sub-menu.
+      *
+      * @param title menu title.  Title must contain a keyboard shortcut,
+      * denoted by prefixing a letter with "&amp;", e.g. "&amp;File"
+      * @return the new sub-menu
+      */
+     public TSubMenu addSubMenu(final String title) {
+         return menu.addSubMenu(title);
+     }
+ }
index 0000000000000000000000000000000000000000,2c10393c0615ca25c744fbc2128d659d1bb08e3d..2c10393c0615ca25c744fbc2128d659d1bb08e3d
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,33 +1,33 @@@
+ /*
+  * Jexer - Java Text User Interface
+  *
+  * The MIT License (MIT)
+  *
+  * 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"),
+  * to deal in the Software without restriction, including without limitation
+  * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+  * and/or sell copies of the Software, and to permit persons to whom the
+  * Software is furnished to do so, subject to the following conditions:
+  *
+  * The above copyright notice and this permission notice shall be included in
+  * all copies or substantial portions of the Software.
+  *
+  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+  * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+  * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+  * DEALINGS IN THE SOFTWARE.
+  *
+  * @author Kevin Lamonte [kevin.lamonte@gmail.com]
+  * @version 1
+  */
+ /**
+  * Menu bar support classes.
+  */
+ package jexer.menu;
index 0000000000000000000000000000000000000000,be3ab507976b72e81d6763b2773b27a6d97d9a34..be3ab507976b72e81d6763b2773b27a6d97d9a34
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,1399 +1,1399 @@@
+ /*
+  * Jexer - Java Text User Interface
+  *
+  * The MIT License (MIT)
+  *
+  * 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"),
+  * to deal in the Software without restriction, including without limitation
+  * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+  * and/or sell copies of the Software, and to permit persons to whom the
+  * Software is furnished to do so, subject to the following conditions:
+  *
+  * The above copyright notice and this permission notice shall be included in
+  * all copies or substantial portions of the Software.
+  *
+  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+  * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+  * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+  * DEALINGS IN THE SOFTWARE.
+  *
+  * @author Kevin Lamonte [kevin.lamonte@gmail.com]
+  * @version 1
+  */
+ package jexer.net;
+ import java.io.InputStream;
+ import java.io.IOException;
+ import java.util.ArrayList;
+ import java.util.Map;
+ import java.util.TreeMap;
+ import jexer.backend.SessionInfo;
+ import static jexer.net.TelnetSocket.*;
+ /**
+  * TelnetInputStream works with TelnetSocket to perform the telnet protocol.
+  */
+ public class TelnetInputStream extends InputStream implements SessionInfo {
+     // ------------------------------------------------------------------------
+     // Constants --------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     // Variables --------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * The root TelnetSocket that has my telnet protocol state.
+      */
+     private TelnetSocket master;
+     /**
+      * The raw socket's InputStream.
+      */
+     private InputStream input;
+     /**
+      * The telnet-aware OutputStream.
+      */
+     private TelnetOutputStream output;
+     /**
+      * Persistent read buffer.  In practice this will only be used if the
+      * single-byte read() is called sometime.
+      */
+     private byte [] readBuffer;
+     /**
+      * Current writing position in readBuffer - what is passed into
+      * input.read().
+      */
+     private int readBufferEnd;
+     /**
+      * Current read position in readBuffer - what is passed to the client in
+      * response to this.read().
+      */
+     private int readBufferStart;
+     /**
+      * User name.
+      */
+     private String username = "";
+     /**
+      * Language.
+      */
+     private String language = "en_US";
+     /**
+      * Text window width.
+      */
+     private int windowWidth = 80;
+     /**
+      * Text window height.
+      */
+     private int windowHeight = 24;
+     /**
+      * When true, the last read byte from the remote side was IAC.
+      */
+     private boolean iac = false;
+     /**
+      * When true, we are in the middle of a DO/DONT/WILL/WONT negotiation.
+      */
+     private boolean dowill = false;
+     /**
+      * The telnet option being negotiated.
+      */
+     private int dowillType = 0;
+     /**
+      * When true, we are waiting to see the end of the sub-negotiation
+      * sequence.
+      */
+     private boolean subnegEnd = false;
+     /**
+      * When true, the last byte read from the remote side was CR.
+      */
+     private boolean readCR = false;
+     /**
+      * The subnegotiation buffer.
+      */
+     private ArrayList<Byte> subnegBuffer;
+     // ------------------------------------------------------------------------
+     // Constructors -----------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Package private constructor.
+      *
+      * @param master the master TelnetSocket
+      * @param input the underlying socket's InputStream
+      * @param output the telnet-aware OutputStream
+      */
+     TelnetInputStream(final TelnetSocket master, final InputStream input,
+         final TelnetOutputStream output) {
+         this.master = master;
+         this.input  = input;
+         this.output = output;
+         // Setup new read buffer
+         readBuffer      = new byte[1024];
+         readBufferStart = 0;
+         readBufferEnd   = 0;
+         subnegBuffer    = new ArrayList<Byte>();
+     }
+     // ------------------------------------------------------------------------
+     // SessionInfo ------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Username getter.
+      *
+      * @return the username
+      */
+     public String getUsername() {
+         return this.username;
+     }
+     /**
+      * Username setter.
+      *
+      * @param username the value
+      */
+     public void setUsername(final String username) {
+         this.username = username;
+     }
+     /**
+      * Language getter.
+      *
+      * @return the language
+      */
+     public String getLanguage() {
+         return this.language;
+     }
+     /**
+      * Language setter.
+      *
+      * @param language the value
+      */
+     public void setLanguage(final String language) {
+         this.language = language;
+     }
+     /**
+      * Get the terminal type as reported by the telnet Terminal Type option.
+      *
+      * @return the terminal type
+      */
+     public String getTerminalType() {
+         return master.terminalType;
+     }
+     /**
+      * Text window width getter.
+      *
+      * @return the window width
+      */
+     public int getWindowWidth() {
+         return windowWidth;
+     }
+     /**
+      * Text window height getter.
+      *
+      * @return the window height
+      */
+     public int getWindowHeight() {
+         return windowHeight;
+     }
+     /**
+      * Re-query the text window size.
+      */
+     public void queryWindowSize() {
+         // NOP
+     }
+     // ------------------------------------------------------------------------
+     // InputStream ------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Returns an estimate of the number of bytes that can be read (or
+      * skipped over) from this input stream without blocking by the next
+      * invocation of a method for this input stream.
+      *
+      * @return an estimate of the number of bytes that can be read (or
+      * skipped over) from this input stream without blocking or 0 when it
+      * reaches the end of the input stream.
+      * @throws IOException if an I/O error occurs
+      */
+     @Override
+     public int available() throws IOException {
+         if (readBuffer == null) {
+             throw new IOException("InputStream is closed");
+         }
+         if (readBufferEnd - readBufferStart > 0) {
+             return (readBufferEnd - readBufferStart);
+         }
+         return input.available();
+     }
+     /**
+      * Closes this input stream and releases any system resources associated
+      * with the stream.
+      *
+      * @throws IOException if an I/O error occurs
+      */
+     @Override
+     public void close() throws IOException {
+         if (readBuffer != null) {
+             readBuffer = null;
+             input.close();
+         }
+     }
+     /**
+      * Marks the current position in this input stream.
+      *
+      * @param readLimit the maximum limit of bytes that can be read before
+      * the mark position becomes invalid
+      */
+     @Override
+     public void mark(final int readLimit) {
+         // Do nothing
+     }
+     /**
+      * Tests if this input stream supports the mark and reset methods.
+      *
+      * @return true if this stream instance supports the mark and reset
+      * methods; false otherwise
+      */
+     @Override
+     public boolean markSupported() {
+         return false;
+     }
+     /**
+      * Reads the next byte of data from the input stream.
+      *
+      * @return the next byte of data, or -1 if there is no more data because
+      * the end of the stream has been reached.
+      * @throws IOException if an I/O error occurs
+      */
+     @Override
+     public int read() throws IOException {
+         // If the post-processed buffer has bytes, use that.
+         if (readBufferEnd - readBufferStart > 0) {
+             readBufferStart++;
+             return readBuffer[readBufferStart - 1];
+         }
+         // The buffer is empty, so reset the indexes to 0.
+         readBufferStart = 0;
+         readBufferEnd   = 0;
+         // Read some fresh data and run it through the telnet protocol.
+         int rc = readImpl(readBuffer, readBufferEnd,
+             readBuffer.length - readBufferEnd);
+         // If we got something, return it.
+         if (rc > 0) {
+             readBufferEnd += rc;
+             readBufferStart++;
+             return readBuffer[readBufferStart - 1];
+         }
+         // If we read 0, I screwed up big time.
+         assert (rc != 0);
+         // We read -1 (EOF).
+         return rc;
+     }
+     /**
+      * Reads some number of bytes from the input stream and stores them into
+      * the buffer array b.
+      *
+      * @param b the buffer into which the data is read.
+      * @return the total number of bytes read into the buffer, or -1 if there
+      * is no more data because the end of the stream has been reached.
+      * @throws IOException if an I/O error occurs
+      */
+     @Override
+     public int read(final byte[] b) throws IOException {
+         return read(b, 0, b.length);
+     }
+     /**
+      * Reads up to len bytes of data from the input stream into an array of
+      * bytes.
+      *
+      * @param b the buffer into which the data is read.
+      * @param off the start offset in array b at which the data is written.
+      * @param len the maximum number of bytes to read.
+      * @return the total number of bytes read into the buffer, or -1 if there
+      * is no more data because the end of the stream has been reached.
+      * @throws IOException if an I/O error occurs
+      */
+     @Override
+     public int read(final byte[] b, final int off,
+         final int len) throws IOException {
+         // The only time we can return 0 is if len is 0, as per the
+         // InputStream contract.
+         if (len == 0) {
+             return 0;
+         }
+         // If the post-processed buffer has bytes, use that.
+         if (readBufferEnd - readBufferStart > 0) {
+             int n = Math.min(len, readBufferEnd - readBufferStart);
+             System.arraycopy(b, off, readBuffer, readBufferStart, n);
+             readBufferStart += n;
+             return n;
+         }
+         // The buffer is empty, so reset the indexes to 0.
+         readBufferStart = 0;
+         readBufferEnd   = 0;
+         // The maximum number of bytes we will ask for will definitely be
+         // within the bounds of what we can return in a single call.
+         int n = Math.min(len, readBuffer.length);
+         // Read some fresh data and run it through the telnet protocol.
+         int rc = readImpl(readBuffer, readBufferEnd, n);
+         // If we got something, return it.
+         if (rc > 0) {
+             System.arraycopy(readBuffer, 0, b, off, rc);
+             return rc;
+         }
+         // If we read 0, I screwed up big time.
+         assert (rc != 0);
+         // We read -1 (EOF).
+         return rc;
+     }
+     /**
+      * Repositions this stream to the position at the time the mark method
+      * was last called on this input stream.  This is not supported by
+      * TelnetInputStream, so IOException is always thrown.
+      *
+      * @throws IOException if this function is used
+      */
+     @Override
+     public void reset() throws IOException {
+         throw new IOException("InputStream does not support mark/reset");
+     }
+     /**
+      * Skips over and discards n bytes of data from this input stream.
+      *
+      * @param n the number of bytes to be skipped
+      * @return the actual number of bytes skipped
+      * @throws IOException if an I/O error occurs
+      */
+     @Override
+     public long skip(final long n) throws IOException {
+         if (n < 0) {
+             return 0;
+         }
+         for (int i = 0; i < n; i++) {
+             read();
+         }
+         return n;
+     }
+     // ------------------------------------------------------------------------
+     // TelnetInputStream ------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * For debugging, return a descriptive string for this telnet option.
+      * These are pulled from: http://www.iana.org/assignments/telnet-options
+      *
+      * @param option the telnet option byte
+      * @return a string describing the telnet option code
+      */
+     @SuppressWarnings("unused")
+     private String optionString(final int option) {
+         switch (option) {
+         case 0: return "Binary Transmission";
+         case 1: return "Echo";
+         case 2: return "Reconnection";
+         case 3: return "Suppress Go Ahead";
+         case 4: return "Approx Message Size Negotiation";
+         case 5: return "Status";
+         case 6: return "Timing Mark";
+         case 7: return "Remote Controlled Trans and Echo";
+         case 8: return "Output Line Width";
+         case 9: return "Output Page Size";
+         case 10: return "Output Carriage-Return Disposition";
+         case 11: return "Output Horizontal Tab Stops";
+         case 12: return "Output Horizontal Tab Disposition";
+         case 13: return "Output Formfeed Disposition";
+         case 14: return "Output Vertical Tabstops";
+         case 15: return "Output Vertical Tab Disposition";
+         case 16: return "Output Linefeed Disposition";
+         case 17: return "Extended ASCII";
+         case 18: return "Logout";
+         case 19: return "Byte Macro";
+         case 20: return "Data Entry Terminal";
+         case 21: return "SUPDUP";
+         case 22: return "SUPDUP Output";
+         case 23: return "Send Location";
+         case 24: return "Terminal Type";
+         case 25: return "End of Record";
+         case 26: return "TACACS User Identification";
+         case 27: return "Output Marking";
+         case 28: return "Terminal Location Number";
+         case 29: return "Telnet 3270 Regime";
+         case 30: return "X.3 PAD";
+         case 31: return "Negotiate About Window Size";
+         case 32: return "Terminal Speed";
+         case 33: return "Remote Flow Control";
+         case 34: return "Linemode";
+         case 35: return "X Display Location";
+         case 36: return "Environment Option";
+         case 37: return "Authentication Option";
+         case 38: return "Encryption Option";
+         case 39: return "New Environment Option";
+         case 40: return "TN3270E";
+         case 41: return "XAUTH";
+         case 42: return "CHARSET";
+         case 43: return "Telnet Remote Serial Port (RSP)";
+         case 44: return "Com Port Control Option";
+         case 45: return "Telnet Suppress Local Echo";
+         case 46: return "Telnet Start TLS";
+         case 47: return "KERMIT";
+         case 48: return "SEND-URL";
+         case 49: return "FORWARD_X";
+         case 138: return "TELOPT PRAGMA LOGON";
+         case 139: return "TELOPT SSPI LOGON";
+         case 140: return "TELOPT PRAGMA HEARTBEAT";
+         case 255: return "Extended-Options-List";
+         default:
+             if ((option >= 50) && (option <= 137)) {
+                 return "Unassigned";
+             }
+             return "UNKNOWN - OTHER";
+         }
+     }
+     /**
+      * Send a DO/DON'T/WILL/WON'T response to the remote side.
+      *
+      * @param response a TELNET_DO/DONT/WILL/WONT byte
+      * @param option telnet option byte (binary mode, term type, etc.)
+      * @throws IOException if an I/O error occurs
+      */
+     private void respond(final int response,
+         final int option) throws IOException {
+         byte [] buffer = new byte[3];
+         buffer[0] = (byte) TELNET_IAC;
+         buffer[1] = (byte) response;
+         buffer[2] = (byte) option;
+         output.rawWrite(buffer);
+     }
+     /**
+      * Tell the remote side we WILL support an option.
+      *
+      * @param option telnet option byte (binary mode, term type, etc.)
+      * @throws IOException if an I/O error occurs
+      */
+     private void WILL(final int option) throws IOException {
+         respond(TELNET_WILL, option);
+     }
+     /**
+      * Tell the remote side we WON'T support an option.
+      *
+      * @param option telnet option byte (binary mode, term type, etc.)
+      * @throws IOException if an I/O error occurs
+      */
+     private void WONT(final int option) throws IOException {
+         respond(TELNET_WONT, option);
+     }
+     /**
+      * Tell the remote side we DO support an option.
+      *
+      * @param option telnet option byte (binary mode, term type, etc.)
+      * @throws IOException if an I/O error occurs
+      */
+     private void DO(final int option) throws IOException {
+         respond(TELNET_DO, option);
+     }
+     /**
+      * Tell the remote side we DON'T support an option.
+      *
+      * @param option telnet option byte (binary mode, term type, etc.)
+      * @throws IOException if an I/O error occurs
+      */
+     private void DONT(final int option) throws IOException {
+         respond(TELNET_DONT, option);
+     }
+     /**
+      * Tell the remote side we WON't or DON'T support an option.
+      *
+      * @param remoteQuery a TELNET_DO/DONT/WILL/WONT byte
+      * @param option telnet option byte (binary mode, term type, etc.)
+      * @throws IOException if an I/O error occurs
+      */
+     private void refuse(final int remoteQuery,
+         final int option) throws IOException {
+         if (remoteQuery == TELNET_DO) {
+             WONT(option);
+         } else {
+             DONT(option);
+         }
+     }
+     /**
+      * Build sub-negotiation packet (RFC 855).
+      *
+      * @param option telnet option
+      * @param response output buffer of response bytes
+      * @throws IOException if an I/O error occurs
+      */
+     private void telnetSendSubnegResponse(final int option,
+         final byte [] response) throws IOException {
+         byte [] buffer = new byte[response.length + 5];
+         buffer[0] = (byte) TELNET_IAC;
+         buffer[1] = (byte) TELNET_SB;
+         buffer[2] = (byte) option;
+         System.arraycopy(response, 0, buffer, 3, response.length);
+         buffer[response.length + 3] = (byte) TELNET_IAC;
+         buffer[response.length + 4] = (byte) TELNET_SE;
+         output.rawWrite(buffer);
+     }
+     /**
+      * Telnet option: Terminal Speed (RFC 1079).  Client side.
+      *
+      * @throws IOException if an I/O error occurs
+      */
+     private void telnetSendTerminalSpeed() throws IOException {
+         byte [] response = {0, '3', '8', '4', '0', '0', ',',
+                             '3', '8', '4', '0', '0'};
+         telnetSendSubnegResponse(32, response);
+     }
+     /**
+      * Telnet option: Terminal Type (RFC 1091).  Client side.
+      *
+      * @throws IOException if an I/O error occurs
+      */
+     private void telnetSendTerminalType() throws IOException {
+         byte [] response = {0, 'v', 't', '1', '0', '0' };
+         telnetSendSubnegResponse(24, response);
+     }
+     /**
+      * Telnet option: Terminal Type (RFC 1091).  Server side.
+      *
+      * @throws IOException if an I/O error occurs
+      */
+     private void requestTerminalType() throws IOException {
+         byte [] response = new byte[1];
+         response[0] = 1;
+         telnetSendSubnegResponse(24, response);
+     }
+     /**
+      * Telnet option: Terminal Speed (RFC 1079).  Server side.
+      *
+      * @throws IOException if an I/O error occurs
+      */
+     private void requestTerminalSpeed() throws IOException {
+         byte [] response = new byte[1];
+         response[0] = 1;
+         telnetSendSubnegResponse(32, response);
+     }
+     /**
+      * Telnet option: New Environment (RFC 1572).  Server side.
+      *
+      * @throws IOException if an I/O error occurs
+      */
+     private void requestEnvironment() throws IOException {
+         byte [] response = new byte[1];
+         response[0] = 1;
+         telnetSendSubnegResponse(39, response);
+     }
+     /**
+      * Send the options we want to negotiate on.
+      *
+      * <p>The options we use are:
+      *
+      * <p>
+      * <pre>
+      *     Binary Transmission           RFC 856
+      *     Suppress Go Ahead             RFC 858
+      *     Negotiate About Window Size   RFC 1073
+      *     Terminal Type                 RFC 1091
+      *     Terminal Speed                RFC 1079
+      *     New Environment               RFC 1572
+      *
+      * When run as a server:
+      *     Echo                          RFC 857
+      * </pre>
+      *
+      * @throws IOException if an I/O error occurs
+      */
+     void telnetSendOptions() throws IOException {
+         if (master.binaryMode == false) {
+             // Binary Transmission: must ask both do and will
+             DO(0);
+             WILL(0);
+         }
+         if (master.goAhead == true) {
+             // Suppress Go Ahead
+             DO(3);
+             WILL(3);
+         }
+         // Server only options
+         if (master.isServer == true) {
+             // Enable Echo - I echo to them, they do not echo back to me.
+             DONT(1);
+             WILL(1);
+             if (master.doTermType == true) {
+                 // Terminal type - request it
+                 DO(24);
+             }
+             if (master.doTermSpeed == true) {
+                 // Terminal speed - request it
+                 DO(32);
+             }
+             if (master.doNAWS == true) {
+                 // NAWS - request it
+                 DO(31);
+             }
+             if (master.doEnvironment == true) {
+                 // Environment - request it
+                 DO(39);
+             }
+         } else {
+             if (master.doTermType == true) {
+                 // Terminal type - request it
+                 WILL(24);
+             }
+             if (master.doTermSpeed == true) {
+                 // Terminal speed - request it
+                 WILL(32);
+             }
+             if (master.doNAWS == true) {
+                 // NAWS - request it
+                 WILL(31);
+             }
+             if (master.doEnvironment == true) {
+                 // Environment - request it
+                 WILL(39);
+             }
+         }
+         // Push it all out
+         output.flush();
+     }
+     /**
+      * New Environment parsing state.
+      */
+     private enum EnvState {
+         INIT,
+         TYPE,
+         NAME,
+         VALUE
+     }
+     /**
+      * Handle the New Environment option.  Note that this implementation
+      * fails to handle ESC as defined in RFC 1572.
+      */
+     private void handleNewEnvironment() {
+         Map<String, String> newEnv = new TreeMap<String, String>();
+         EnvState state = EnvState.INIT;
+         StringBuilder name = new StringBuilder();
+         StringBuilder value = new StringBuilder();
+         /*
+         System.err.printf("handleNewEnvironment() %d bytes\n",
+             subnegBuffer.size());
+          */
+         for (int i = 1; i < subnegBuffer.size(); i++) {
+             Byte b = subnegBuffer.get(i);
+             /*
+             System.err.printf("   b: %c %d 0x%02x\n", (char)b.byteValue(),
+                 b, b);
+              */
+             switch (state) {
+             case INIT:
+                 // Looking for "IS"
+                 if (b == 0) {
+                     state = EnvState.TYPE;
+                 } else {
+                     // The other side isn't following the rules, see ya.
+                     return;
+                 }
+                 break;
+             case TYPE:
+                 // Looking for "VAR" or "USERVAR"
+                 if (b == 0) {
+                     // VAR
+                     state = EnvState.NAME;
+                     name = new StringBuilder();
+                 } else if (b == 3) {
+                     // USERVAR
+                     state = EnvState.NAME;
+                     name = new StringBuilder();
+                 } else {
+                     // The other side isn't following the rules, see ya
+                     return;
+                 }
+                 break;
+             case NAME:
+                 // Looking for "VALUE" or a name byte
+                 if (b == 1) {
+                     // VALUE
+                     state = EnvState.VALUE;
+                     value = new StringBuilder();
+                 } else {
+                     // Take it as an environment variable name/key byte
+                     name.append((char)b.byteValue());
+                 }
+                 break;
+             case VALUE:
+                 // Looking for "VAR", "USERVAR", or a name byte, or the end
+                 if (b == 0) {
+                     // VAR
+                     state = EnvState.NAME;
+                     if (value.length() > 0) {
+                         /*
+                         System.err.printf("NAME: '%s' VALUE: '%s'\n",
+                             name, value);
+                          */
+                         newEnv.put(name.toString(), value.toString());
+                     }
+                     name = new StringBuilder();
+                 } else if (b == 3) {
+                     // USERVAR
+                     state = EnvState.NAME;
+                     if (value.length() > 0) {
+                         /*
+                         System.err.printf("NAME: '%s' VALUE: '%s'\n",
+                             name, value);
+                          */
+                         newEnv.put(name.toString(), value.toString());
+                     }
+                     name = new StringBuilder();
+                 } else {
+                     // Take it as an environment variable value byte
+                     value.append((char)b.byteValue());
+                 }
+                 break;
+             default:
+                 throw new RuntimeException("Invalid state: " + state);
+             }
+         }
+         if ((name.length() > 0) && (value.length() > 0)) {
+             /*
+             System.err.printf("NAME: '%s' VALUE: '%s'\n", name, value);
+              */
+             newEnv.put(name.toString(), value.toString());
+         }
+         for (String key: newEnv.keySet()) {
+             if (key.equals("LANG")) {
+                 language = newEnv.get(key);
+             }
+             if (key.equals("LOGNAME")) {
+                 username = newEnv.get(key);
+             }
+             if (key.equals("USER")) {
+                 username = newEnv.get(key);
+             }
+         }
+     }
+     /**
+      * Handle an option sub-negotiation.
+      *
+      * @throws IOException if an I/O error occurs
+      */
+     private void handleSubneg() throws IOException {
+         Byte option;
+         // Sanity check: there must be at least 1 byte in subnegBuffer
+         if (subnegBuffer.size() < 1) {
+             // Buffer too small: the other side is a broken telnetd, it did
+             // not send the right sub-negotiation data.  Bail out now.
+             return;
+         }
+         option = subnegBuffer.get(0);
+         switch (option) {
+         case 24:
+             // Terminal Type
+             if ((subnegBuffer.size() > 1) && (subnegBuffer.get(1) == 1)) {
+                 // Server sent "SEND", we say "IS"
+                 telnetSendTerminalType();
+             }
+             if ((subnegBuffer.size() > 1) && (subnegBuffer.get(1) == 0)) {
+                 // Client sent "IS", record it
+                 StringBuilder terminalString = new StringBuilder();
+                 for (int i = 2; i < subnegBuffer.size(); i++) {
+                     terminalString.append((char)subnegBuffer.
+                         get(i).byteValue());
+                 }
+                 master.terminalType = terminalString.toString();
+                 /*
+                 System.err.printf("terminal type: '%s'\n",
+                     master.terminalType);
+                  */
+             }
+             break;
+         case 32:
+             // Terminal Speed
+             if ((subnegBuffer.size() > 1) && (subnegBuffer.get(1) == 1)) {
+                 // Server sent "SEND", we say "IS"
+                 telnetSendTerminalSpeed();
+             }
+             if ((subnegBuffer.size() > 1) && (subnegBuffer.get(1) == 0)) {
+                 // Client sent "IS", record it
+                 StringBuilder speedString = new StringBuilder();
+                 for (int i = 2; i < subnegBuffer.size(); i++) {
+                     speedString.append((char)subnegBuffer.get(i).byteValue());
+                 }
+                 master.terminalSpeed = speedString.toString();
+                 /*
+                 System.err.printf("terminal speed: '%s'\n",
+                     master.terminalSpeed);
+                  */
+             }
+             break;
+         case 31:
+             // NAWS
+             if (subnegBuffer.size() >= 5) {
+                 int i = 0;
+                 i++;
+                 if (subnegBuffer.get(i) == (byte) TELNET_IAC) {
+                     i++;
+                 }
+                 int width = subnegBuffer.get(i);
+                 if (width < 0) {
+                     width += 256;
+                 }
+                 windowWidth = width * 256;
+                 i++;
+                 if (subnegBuffer.get(i) == (byte) TELNET_IAC) {
+                     i++;
+                 }
+                 width = subnegBuffer.get(i);
+                 windowWidth += width;
+                 if (width < 0) {
+                     windowWidth += 256;
+                 }
+                 i++;
+                 if (subnegBuffer.get(i) == (byte) TELNET_IAC) {
+                     i++;
+                 }
+                 int height = subnegBuffer.get(i);
+                 if (height < 0) {
+                     height += 256;
+                 }
+                 windowHeight = height * 256;
+                 i++;
+                 if (subnegBuffer.get(i) == (byte) TELNET_IAC) {
+                     i++;
+                 }
+                 height = subnegBuffer.get(i);
+                 windowHeight += height;
+                 if (height < 0) {
+                     windowHeight += 256;
+                 }
+             }
+             break;
+         case 39:
+             // Environment
+             handleNewEnvironment();
+             break;
+         default:
+             // Ignore this one
+             break;
+         }
+     }
+     /**
+      * Reads up to len bytes of data from the input stream into an array of
+      * bytes.
+      *
+      * @param buf the buffer into which the data is read.
+      * @param off the start offset in array b at which the data is written.
+      * @param len the maximum number of bytes to read.
+      * @return the total number of bytes read into the buffer, or -1 if there
+      * is no more data because the end of the stream has been reached.
+      * @throws IOException if an I/O error occurs
+      */
+     private int readImpl(final byte[] buf, final int off,
+         final int len) throws IOException {
+         assert (len > 0);
+         // The current writing position in buf.
+         int bufN = off;
+         // We will keep trying to read() until we have something to return.
+         do {
+             byte [] buffer = null;
+             if (master.binaryMode) {
+                 // Binary mode: read up to len bytes.  There will never be
+                 // more bytes to pass upstream than there are bytes on the
+                 // wire.
+                 buffer = new byte[len];
+             } else {
+                 // ASCII mode: read up to len - 2 bytes.  There may have been
+                 // some combination of IAC, CR, and NUL from a previous
+                 // readImpl() that could result in more bytes to pass up than
+                 // are on the wire.
+                 buffer = new byte[len - 2];
+             }
+             int bufferN = 0;
+             // Read some data from the other end
+             int rc = input.read(buffer);
+             // Check for EOF or error
+             if (rc > 0) {
+                 // More data came in
+                 bufferN = rc;
+             } else {
+                 // EOF, just return it.
+                 return rc;
+             }
+             // Loop through the read bytes
+             for (int i = 0; i < bufferN; i++) {
+                 byte b = buffer[i];
+                 if (subnegEnd == true) {
+                     // Looking for IAC SE to end this subnegotiation
+                     if (b == (byte) TELNET_SE) {
+                         if (iac == true) {
+                             iac = false;
+                             subnegEnd = false;
+                             handleSubneg();
+                         }
+                     } else if (b == (byte) TELNET_IAC) {
+                         if (iac == true) {
+                             // An argument to the subnegotiation option
+                             subnegBuffer.add((byte) TELNET_IAC);
+                         } else {
+                             iac = true;
+                         }
+                     } else {
+                         // An argument to the subnegotiation option
+                         subnegBuffer.add(b);
+                     }
+                     continue;
+                 }
+                 // Look for DO/DON'T/WILL/WON'T option
+                 if (dowill == true) {
+                     // Look for option/
+                     switch (b) {
+                     case 0:
+                         // Binary Transmission
+                         if (dowillType == (byte) TELNET_WILL) {
+                             // Server will use binary transmission, yay.
+                             master.binaryMode = true;
+                         } else if (dowillType == (byte) TELNET_DO) {
+                             // Server asks for binary transmission.
+                             WILL(b);
+                             master.binaryMode = true;
+                         } else if (dowillType == (byte) TELNET_WONT) {
+                             // We're screwed, server won't do binary
+                             // transmission.
+                             master.binaryMode = false;
+                         } else {
+                             // Server demands NVT ASCII mode.
+                             master.binaryMode = false;
+                         }
+                         break;
+                     case 1:
+                         // Echo
+                         if (dowillType == (byte) TELNET_WILL) {
+                             // Server will use echo, yay.
+                             master.echoMode = true;
+                         } else if (dowillType == (byte) TELNET_DO) {
+                             // Server asks for echo.
+                             WILL(b);
+                             master.echoMode = true;
+                         } else if (dowillType == (byte) TELNET_WONT) {
+                             // We're screwed, server won't do echo.
+                             master.echoMode = false;
+                         } else {
+                             // Server demands no echo.
+                             master.echoMode = false;
+                         }
+                         break;
+                     case 3:
+                         // Suppress Go Ahead
+                         if (dowillType == (byte) TELNET_WILL) {
+                             // Server will use suppress go-ahead, yay.
+                             master.goAhead = false;
+                         } else if (dowillType == (byte) TELNET_DO) {
+                             // Server asks for suppress go-ahead.
+                             WILL(b);
+                             master.goAhead = false;
+                         } else if (dowillType == (byte) TELNET_WONT) {
+                             // We're screwed, server won't do suppress
+                             // go-ahead.
+                             master.goAhead = true;
+                         } else {
+                             // Server demands Go-Ahead mode.
+                             master.goAhead = true;
+                         }
+                         break;
+                     case 24:
+                         // Terminal Type - send what's in TERM
+                         if (dowillType == (byte) TELNET_WILL) {
+                             // Server will use terminal type, yay.
+                             if (master.isServer
+                                 && master.doTermType
+                             ) {
+                                 requestTerminalType();
+                                 master.doTermType = false;
+                             } else if (!master.isServer) {
+                                 master.doTermType = true;
+                             }
+                         } else if (dowillType == (byte) TELNET_DO) {
+                             // Server asks for terminal type.
+                             WILL(b);
+                             master.doTermType = true;
+                         } else if (dowillType == (byte) TELNET_WONT) {
+                             // We're screwed, server won't do terminal type.
+                             master.doTermType = false;
+                         } else {
+                             // Server will not listen to terminal type.
+                             master.doTermType = false;
+                         }
+                         break;
+                     case 31:
+                         // NAWS
+                         if (dowillType == (byte) TELNET_WILL) {
+                             // Server will use NAWS, yay.
+                             master.doNAWS = true;
+                             // NAWS cannot be requested by the server, it is
+                             // only sent by the client.
+                         } else if (dowillType == (byte) TELNET_DO) {
+                             // Server asks for NAWS.
+                             WILL(b);
+                             master.doNAWS = true;
+                         } else if (dowillType == (byte) TELNET_WONT) {
+                             // Server won't do NAWS.
+                             master.doNAWS = false;
+                         } else {
+                             // Server will not listen to NAWS.
+                             master.doNAWS = false;
+                         }
+                         break;
+                     case 32:
+                         // Terminal Speed
+                         if (dowillType == (byte) TELNET_WILL) {
+                             // Server will use terminal speed, yay.
+                             if (master.isServer
+                                 && master.doTermSpeed
+                             ) {
+                                 requestTerminalSpeed();
+                                 master.doTermSpeed = false;
+                             } else if (!master.isServer) {
+                                 master.doTermSpeed = true;
+                             }
+                         } else if (dowillType == (byte) TELNET_DO) {
+                             // Server asks for terminal speed.
+                             WILL(b);
+                             master.doTermSpeed = true;
+                         } else if (dowillType == (byte) TELNET_WONT) {
+                             // We're screwed, server won't do terminal speed.
+                             master.doTermSpeed = false;
+                         } else {
+                             // Server will not listen to terminal speed.
+                             master.doTermSpeed = false;
+                         }
+                         break;
+                     case 39:
+                         // New Environment
+                         if (dowillType == (byte) TELNET_WILL) {
+                             // Server will use NewEnvironment, yay.
+                             if (master.isServer
+                                 && master.doEnvironment
+                             ) {
+                                 requestEnvironment();
+                                 master.doEnvironment = false;
+                             } else if (!master.isServer) {
+                                 master.doEnvironment = true;
+                             }
+                         } else if (dowillType == (byte) TELNET_DO) {
+                             // Server asks for NewEnvironment.
+                             WILL(b);
+                             master.doEnvironment = true;
+                         } else if (dowillType == (byte) TELNET_WONT) {
+                             // Server won't do NewEnvironment.
+                             master.doEnvironment = false;
+                         } else {
+                             // Server will not listen to New Environment.
+                             master.doEnvironment = false;
+                         }
+                         break;
+                     default:
+                         // Other side asked for something we don't
+                         // understand.  Tell them we will not do this option.
+                         refuse(dowillType, b);
+                         break;
+                     }
+                     dowill = false;
+                     continue;
+                 } // if (dowill == true)
+                 // Perform read processing
+                 if (b == (byte) TELNET_IAC) {
+                     // Telnet command
+                     if (iac == true) {
+                         // IAC IAC -> IAC
+                         buf[bufN++] = (byte) TELNET_IAC;
+                         iac = false;
+                     } else {
+                         iac = true;
+                     }
+                     continue;
+                 } else {
+                     if (iac == true) {
+                         switch (b) {
+                         case (byte) TELNET_SE:
+                             // END Sub-Negotiation
+                             break;
+                         case (byte) TELNET_NOP:
+                             // NOP
+                             break;
+                         case (byte) TELNET_DM:
+                             // Data Mark
+                             break;
+                         case (byte) TELNET_BRK:
+                             // Break
+                             break;
+                         case (byte) TELNET_IP:
+                             // Interrupt Process
+                             break;
+                         case (byte) TELNET_AO:
+                             // Abort Output
+                             break;
+                         case (byte) TELNET_AYT:
+                             // Are You There?
+                             break;
+                         case (byte) TELNET_EC:
+                             // Erase Character
+                             break;
+                         case (byte) TELNET_EL:
+                             // Erase Line
+                             break;
+                         case (byte) TELNET_GA:
+                             // Go Ahead
+                             break;
+                         case (byte) TELNET_SB:
+                             // START Sub-Negotiation
+                             // From here we wait for the IAC SE
+                             subnegEnd = true;
+                             subnegBuffer.clear();
+                             break;
+                         case (byte) TELNET_WILL:
+                             // WILL
+                             dowill = true;
+                             dowillType = b;
+                             break;
+                         case (byte) TELNET_WONT:
+                             // WON'T
+                             dowill = true;
+                             dowillType = b;
+                             break;
+                         case (byte) TELNET_DO:
+                             // DO
+                             dowill = true;
+                             dowillType = b;
+                             break;
+                         case (byte) TELNET_DONT:
+                             // DON'T
+                             dowill = true;
+                             dowillType = b;
+                             break;
+                         default:
+                             // This should be equivalent to IAC NOP
+                             break;
+                         }
+                         iac = false;
+                         continue;
+                     } // if (iac == true)
+                     /*
+                      * All of the regular IAC processing is completed at this
+                      * point.  Now we need to handle the CR and CR LF cases.
+                      *
+                      * According to RFC 854, in NVT ASCII mode:
+                      *     Bare CR -> CR NUL
+                      *     CR LF -> CR LF
+                      *
+                      */
+                     if (master.binaryMode == false) {
+                         if (b == C_LF) {
+                             if (readCR == true) {
+                                 // This is CR LF.  Send CR LF and turn the cr
+                                 // flag off.
+                                 buf[bufN++] = C_CR;
+                                 buf[bufN++] = C_LF;
+                                 readCR = false;
+                                 continue;
+                             }
+                             // This is bare LF.  Send LF.
+                             buf[bufN++] = C_LF;
+                             continue;
+                         }
+                         if (b == C_NUL) {
+                             if (readCR == true) {
+                                 // This is CR NUL.  Send CR and turn the cr
+                                 // flag off.
+                                 buf[bufN++] = C_CR;
+                                 readCR = false;
+                                 continue;
+                             }
+                             // This is bare NUL.  Send NUL.
+                             buf[bufN++] = C_NUL;
+                             continue;
+                         }
+                         if (b == C_CR) {
+                             if (readCR == true) {
+                                 // This is CR CR.  Send a CR NUL and leave
+                                 // the cr flag on.
+                                 buf[bufN++] = C_CR;
+                                 buf[bufN++] = C_NUL;
+                                 continue;
+                             }
+                             // This is the first CR.  Set the cr flag.
+                             readCR = true;
+                             continue;
+                         }
+                         if (readCR == true) {
+                             // This was a bare CR in the stream.
+                             buf[bufN++] = C_CR;
+                             readCR = false;
+                         }
+                         // This is a regular character.  Pass it on.
+                         buf[bufN++] = b;
+                         continue;
+                     }
+                     /*
+                      * This is the case for any of:
+                      *
+                      *     1) A NVT ASCII character that isn't CR, LF, or
+                      *        NUL.
+                      *
+                      *     2) A NVT binary character.
+                      *
+                      * For all of these cases, we just pass the character on.
+                      */
+                     buf[bufN++] = b;
+                 } // if (b == TELNET_IAC)
+             } // for (int i = 0; i < bufferN; i++)
+         } while (bufN == 0);
+         // Return bytes read
+         return bufN;
+     }
+ }
index 0000000000000000000000000000000000000000,6e7536a847f3bcb5e9a68b2c90f38026f2ed2694..6e7536a847f3bcb5e9a68b2c90f38026f2ed2694
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,260 +1,260 @@@
+ /*
+  * Jexer - Java Text User Interface
+  *
+  * The MIT License (MIT)
+  *
+  * 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"),
+  * to deal in the Software without restriction, including without limitation
+  * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+  * and/or sell copies of the Software, and to permit persons to whom the
+  * Software is furnished to do so, subject to the following conditions:
+  *
+  * The above copyright notice and this permission notice shall be included in
+  * all copies or substantial portions of the Software.
+  *
+  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+  * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+  * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+  * DEALINGS IN THE SOFTWARE.
+  *
+  * @author Kevin Lamonte [kevin.lamonte@gmail.com]
+  * @version 1
+  */
+ package jexer.net;
+ import java.io.OutputStream;
+ import java.io.IOException;
+ import static jexer.net.TelnetSocket.*;
+ /**
+  * TelnetOutputStream works with TelnetSocket to perform the telnet protocol.
+  */
+ public class TelnetOutputStream extends OutputStream {
+     // ------------------------------------------------------------------------
+     // Variables --------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * The root TelnetSocket that has my telnet protocol state.
+      */
+     private TelnetSocket master;
+     /**
+      * The raw socket's OutputStream.
+      */
+     private OutputStream output;
+     /**
+      * When true, the last byte the caller passed to write() was a CR.
+      */
+     private boolean writeCR = false;
+     // ------------------------------------------------------------------------
+     // Constructors -----------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Package private constructor.
+      *
+      * @param master the master TelnetSocket
+      * @param output the underlying socket's OutputStream
+      */
+     TelnetOutputStream(final TelnetSocket master, final OutputStream output) {
+         this.master = master;
+         this.output = output;
+     }
+     // ------------------------------------------------------------------------
+     // OutputStrem ------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Closes this output stream and releases any system resources associated
+      * with this stream.
+      *
+      * @throws IOException if an I/O error occurs
+      */
+     @Override
+     public void close() throws IOException {
+         if (output != null) {
+             output.close();
+             output = null;
+         }
+     }
+     /**
+      * Flushes this output stream and forces any buffered output bytes to be
+      * written out.
+      *
+      * @throws IOException if an I/O error occurs
+      */
+     @Override
+     public void flush() throws IOException {
+         if ((master.binaryMode == false) && (writeCR == true)) {
+             // The last byte sent to this.write() was a CR, which was never
+             // actually sent.  So send the CR in ascii mode, then flush.
+             // CR <anything> -> CR NULL
+             output.write(C_CR);
+             output.write(C_NUL);
+             writeCR = false;
+         }
+         output.flush();
+     }
+     /**
+      * Writes b.length bytes from the specified byte array to this output
+      * stream.
+      *
+      * @param b the data.
+      * @throws IOException if an I/O error occurs
+      */
+     @Override
+     public void write(final byte[] b) throws IOException {
+         writeImpl(b, 0, b.length);
+     }
+     /**
+      * Writes len bytes from the specified byte array starting at offset off
+      * to this output stream.
+      *
+      * @param b the data.
+      * @param off the start offset in the data.
+      * @param len the number of bytes to write.
+      * @throws IOException if an I/O error occurs
+      */
+     @Override
+     public void write(final byte[] b, final int off,
+         final int len) throws IOException {
+         writeImpl(b, off, len);
+     }
+     /**
+      * Writes the specified byte to this output stream.
+      *
+      * @param b the byte to write.
+      * @throws IOException if an I/O error occurs
+      */
+     @Override
+     public void write(final int b) throws IOException {
+         byte [] bytes = new byte[1];
+         bytes[0] = (byte) b;
+         writeImpl(bytes, 0, 1);
+     }
+     // ------------------------------------------------------------------------
+     // TelnetOutputStrem ------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Writes b.length bytes from the specified byte array to this output
+      * stream.  Note package private access.
+      *
+      * @param b the data.
+      * @throws IOException if an I/O error occurs
+      */
+     void rawWrite(final byte[] b) throws IOException {
+         output.write(b, 0, b.length);
+     }
+     /**
+      * Writes len bytes from the specified byte array starting at offset off
+      * to this output stream.
+      *
+      * @param b the data.
+      * @param off the start offset in the data.
+      * @param len the number of bytes to write.
+      * @throws IOException if an I/O error occurs
+      */
+     private void writeImpl(final byte[] b, final int off,
+         final int len) throws IOException {
+         byte [] writeBuffer = new byte[Math.max(len, 4)];
+         int writeBufferI = 0;
+         for (int i = 0; i < len; i++) {
+             if (writeBufferI >= writeBuffer.length - 4) {
+                 // Flush what we have generated so far and reset the buffer,
+                 // because the next byte could generate up to 4 output bytes
+                 // (CR <something> <IAC> <IAC>).
+                 output.write(writeBuffer, 0, writeBufferI);
+                 writeBufferI = 0;
+             }
+             // Pull the next byte
+             byte ch = b[i + off];
+             if (master.binaryMode == true) {
+                 if (ch == TELNET_IAC) {
+                     // IAC -> IAC IAC
+                     writeBuffer[writeBufferI++] = (byte) TELNET_IAC;
+                     writeBuffer[writeBufferI++] = (byte) TELNET_IAC;
+                 } else {
+                     // Anything else -> just send
+                     writeBuffer[writeBufferI++] = ch;
+                 }
+                 continue;
+             }
+             // Non-binary mode: more complicated.  We use writeCR to handle
+             // the case that the last byte of b was a CR.
+             // Bare carriage return -> CR NUL
+             if (ch == C_CR) {
+                 if (writeCR == true) {
+                     // Flush the previous CR to the stream.
+                     // CR <anything> -> CR NULL
+                     writeBuffer[writeBufferI++] = (byte) C_CR;
+                     writeBuffer[writeBufferI++] = (byte) C_NUL;
+                 }
+                 writeCR = true;
+             } else if (ch == C_LF) {
+                 if (writeCR == true) {
+                     // CR LF -> CR LF
+                     writeBuffer[writeBufferI++] = (byte) C_CR;
+                     writeBuffer[writeBufferI++] = (byte) C_LF;
+                     writeCR = false;
+                 } else {
+                     // Bare LF -> LF
+                     writeBuffer[writeBufferI++] = ch;
+                 }
+             } else if (ch == TELNET_IAC) {
+                 if (writeCR == true) {
+                     // CR <anything> -> CR NULL
+                     writeBuffer[writeBufferI++] = (byte) C_CR;
+                     writeBuffer[writeBufferI++] = (byte) C_NUL;
+                     writeCR = false;
+                 }
+                 // IAC -> IAC IAC
+                 writeBuffer[writeBufferI++] = (byte) TELNET_IAC;
+                 writeBuffer[writeBufferI++] = (byte) TELNET_IAC;
+             } else {
+                 if (writeCR == true) {
+                     // CR <anything> -> CR NULL
+                     writeBuffer[writeBufferI++] = (byte) C_CR;
+                     writeBuffer[writeBufferI++] = (byte) C_NUL;
+                     writeCR = false;
+                 } else {
+                     // Normal character */
+                     writeBuffer[writeBufferI++] = ch;
+                 }
+             }
+         } // while (i < userbuf.length)
+         if (writeBufferI > 0) {
+             // Flush what we have generated so far and reset the buffer.
+             output.write(writeBuffer, 0, writeBufferI);
+         }
+     }
+ }
index 0000000000000000000000000000000000000000,3c5b3077ba591ff706b564ed3dd5955675e61240..3c5b3077ba591ff706b564ed3dd5955675e61240
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,129 +1,129 @@@
+ /*
+  * Jexer - Java Text User Interface
+  *
+  * The MIT License (MIT)
+  *
+  * 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"),
+  * to deal in the Software without restriction, including without limitation
+  * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+  * and/or sell copies of the Software, and to permit persons to whom the
+  * Software is furnished to do so, subject to the following conditions:
+  *
+  * The above copyright notice and this permission notice shall be included in
+  * all copies or substantial portions of the Software.
+  *
+  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+  * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+  * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+  * DEALINGS IN THE SOFTWARE.
+  *
+  * @author Kevin Lamonte [kevin.lamonte@gmail.com]
+  * @version 1
+  */
+ package jexer.net;
+ import java.io.IOException;
+ import java.net.InetAddress;
+ import java.net.ServerSocket;
+ import java.net.Socket;
+ import java.net.SocketException;
+ /**
+  * This class provides a ServerSocket that return TelnetSocket's in accept().
+  */
+ public class TelnetServerSocket extends ServerSocket {
+     // ------------------------------------------------------------------------
+     // Variables --------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     // Constructors -----------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Creates an unbound server socket.
+      *
+      * @throws IOException if an I/O error occurs
+      */
+     public TelnetServerSocket() throws IOException {
+         super();
+     }
+     /**
+      * Creates a server socket, bound to the specified port.
+      *
+      * @param port the port number, or 0 to use a port number that is
+      * automatically allocated.
+      * @throws IOException if an I/O error occurs
+      */
+     public TelnetServerSocket(final int port) throws IOException {
+         super(port);
+     }
+     /**
+      * Creates a server socket and binds it to the specified local port
+      * number, with the specified backlog.
+      *
+      * @param port the port number, or 0 to use a port number that is
+      * automatically allocated.
+      * @param backlog requested maximum length of the queue of incoming
+      * connections.
+      * @throws IOException if an I/O error occurs
+      */
+     public TelnetServerSocket(final int port,
+         final int backlog) throws IOException {
+         super(port, backlog);
+     }
+     /**
+      * Create a server with the specified port, listen backlog, and local IP
+      * address to bind to.
+      *
+      * @param port the port number, or 0 to use a port number that is
+      * automatically allocated.
+      * @param backlog requested maximum length of the queue of incoming
+      * connections.
+      * @param bindAddr the local InetAddress the server will bind to
+      * @throws IOException if an I/O error occurs
+      */
+     public TelnetServerSocket(final int port, final int backlog,
+         final InetAddress bindAddr) throws IOException {
+         super(port, backlog, bindAddr);
+     }
+     // ------------------------------------------------------------------------
+     // ServerSocket -----------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Listens for a connection to be made to this socket and accepts it. The
+      * method blocks until a connection is made.
+      *
+      * @return the new Socket
+      * @throws IOException if an I/O error occurs
+      */
+     @Override
+     public Socket accept() throws IOException {
+         if (isClosed()) {
+             throw new SocketException("Socket is closed");
+         }
+         if (!isBound()) {
+             throw new SocketException("Socket is not bound");
+         }
+         Socket socket = new TelnetSocket();
+         implAccept(socket);
+         return socket;
+     }
+ }
index 0000000000000000000000000000000000000000,ac8a2782b0ed672e2166026ffe46439a4bc2681b..ac8a2782b0ed672e2166026ffe46439a4bc2681b
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,203 +1,203 @@@
+ /*
+  * Jexer - Java Text User Interface
+  *
+  * The MIT License (MIT)
+  *
+  * 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"),
+  * to deal in the Software without restriction, including without limitation
+  * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+  * and/or sell copies of the Software, and to permit persons to whom the
+  * Software is furnished to do so, subject to the following conditions:
+  *
+  * The above copyright notice and this permission notice shall be included in
+  * all copies or substantial portions of the Software.
+  *
+  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+  * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+  * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+  * DEALINGS IN THE SOFTWARE.
+  *
+  * @author Kevin Lamonte [kevin.lamonte@gmail.com]
+  * @version 1
+  */
+ package jexer.net;
+ import java.io.InputStream;
+ import java.io.IOException;
+ import java.io.OutputStream;
+ import java.net.Socket;
+ /**
+  * This class provides a Socket that performs the telnet protocol to both
+  * establish an 8-bit clean no echo channel and expose window resize events
+  * to the Jexer ECMA48 backend.
+  */
+ public class TelnetSocket extends Socket {
+     // ------------------------------------------------------------------------
+     // Constants --------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     // Telnet protocol special characters.  Note package private access.
+     static final int TELNET_SE         = 240;
+     static final int TELNET_NOP        = 241;
+     static final int TELNET_DM         = 242;
+     static final int TELNET_BRK        = 243;
+     static final int TELNET_IP         = 244;
+     static final int TELNET_AO         = 245;
+     static final int TELNET_AYT        = 246;
+     static final int TELNET_EC         = 247;
+     static final int TELNET_EL         = 248;
+     static final int TELNET_GA         = 249;
+     static final int TELNET_SB         = 250;
+     static final int TELNET_WILL       = 251;
+     static final int TELNET_WONT       = 252;
+     static final int TELNET_DO         = 253;
+     static final int TELNET_DONT       = 254;
+     static final int TELNET_IAC        = 255;
+     static final int C_NUL             = 0x00;
+     static final int C_LF              = 0x0A;
+     static final int C_CR              = 0x0D;
+     // ------------------------------------------------------------------------
+     // Variables --------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * The telnet-aware socket InputStream.
+      */
+     private TelnetInputStream input;
+     /**
+      * The telnet-aware socket OutputStream.
+      */
+     private TelnetOutputStream output;
+     /**
+      * If true, this is a server socket (i.e. created by accept()).
+      */
+     boolean isServer = true;
+     /**
+      * If true, telnet ECHO mode is set such that local echo is off and
+      * remote echo is on.  This is appropriate for server sockets.
+      */
+     boolean echoMode = false;
+     /**
+      * If true, telnet BINARY mode is enabled.  We always want this to
+      * ensure a Unicode-safe stream.
+      */
+     boolean binaryMode = false;
+     /**
+      * If true, the SUPPRESS-GO-AHEAD option is enabled.  We always want
+      * this.
+      */
+     boolean goAhead = true;
+     /**
+      * If true, request the client terminal type.
+      */
+     boolean doTermType = true;
+     /**
+      * If true, request the client terminal speed.
+      */
+     boolean doTermSpeed = true;
+     /**
+      * If true, request the Negotiate About Window Size option to
+      * determine the client text width/height.
+      */
+     boolean doNAWS = true;
+     /**
+      * If true, request the New Environment option to obtain the client
+      * LOGNAME, USER, and LANG variables.
+      */
+     boolean doEnvironment = true;
+     /**
+      * The terminal type reported by the client.
+      */
+     String terminalType = "";
+     /**
+      * The terminal speed reported by the client.
+      */
+     String terminalSpeed = "";
+     // ------------------------------------------------------------------------
+     // Constructors -----------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Creates a Socket that knows the telnet protocol.  Note package private
+      * access, this is only used by TelnetServerSocket.
+      *
+      * @throws IOException if an I/O error occurs
+      */
+     TelnetSocket() throws IOException {
+         super();
+     }
+     // ------------------------------------------------------------------------
+     // Socket -----------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Returns an input stream for this socket.
+      *
+      * @return the input stream
+      * @throws IOException if an I/O error occurs
+      */
+     @Override
+     public InputStream getInputStream() throws IOException {
+         if (input == null) {
+             assert (output == null);
+             output = new TelnetOutputStream(this, super.getOutputStream());
+             input = new TelnetInputStream(this, super.getInputStream(), output);
+             input.telnetSendOptions();
+         }
+         return input;
+     }
+     /**
+      * Returns an output stream for this socket.
+      *
+      * @return the output stream
+      * @throws IOException if an I/O error occurs
+      */
+     @Override
+     public OutputStream getOutputStream() throws IOException {
+         if (output == null) {
+             assert (input == null);
+             output = new TelnetOutputStream(this, super.getOutputStream());
+             input = new TelnetInputStream(this, super.getInputStream(), output);
+             input.telnetSendOptions();
+         }
+         return output;
+     }
+     // ------------------------------------------------------------------------
+     // TelnetSocket -----------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * See if telnet server/client is in ASCII mode.
+      *
+      * @return if true, this connection is in ASCII mode
+      */
+     public boolean isAscii() {
+         return (!binaryMode);
+     }
+ }
index 0000000000000000000000000000000000000000,5d738fb09f847b7123ad735f19ea30d0277d91c7..5d738fb09f847b7123ad735f19ea30d0277d91c7
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,33 +1,33 @@@
+ /*
+  * Jexer - Java Text User Interface
+  *
+  * The MIT License (MIT)
+  *
+  * 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"),
+  * to deal in the Software without restriction, including without limitation
+  * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+  * and/or sell copies of the Software, and to permit persons to whom the
+  * Software is furnished to do so, subject to the following conditions:
+  *
+  * The above copyright notice and this permission notice shall be included in
+  * all copies or substantial portions of the Software.
+  *
+  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+  * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+  * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+  * DEALINGS IN THE SOFTWARE.
+  *
+  * @author Kevin Lamonte [kevin.lamonte@gmail.com]
+  * @version 1
+  */
+ /**
+  * A Telnet-aware ServerSocket that establishes an 8-bit clean data channel.
+  */
+ package jexer.net;
index 0000000000000000000000000000000000000000,300f9731a26ea9728ac27e4ef91e82f0ecbc2f34..300f9731a26ea9728ac27e4ef91e82f0ecbc2f34
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,63 +1,63 @@@
+ /*
+  * Jexer - Java Text User Interface
+  *
+  * The MIT License (MIT)
+  *
+  * 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"),
+  * to deal in the Software without restriction, including without limitation
+  * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+  * and/or sell copies of the Software, and to permit persons to whom the
+  * Software is furnished to do so, subject to the following conditions:
+  *
+  * The above copyright notice and this permission notice shall be included in
+  * all copies or substantial portions of the Software.
+  *
+  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+  * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+  * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+  * DEALINGS IN THE SOFTWARE.
+  *
+  * @author Kevin Lamonte [kevin.lamonte@gmail.com]
+  * @version 1
+  */
+ /**
+  * Jexer - Java Text User Interface library
+  *
+  * <p>
+  * This library is a text-based windowing system loosely reminiscent of
+  * Borland's <a href="http://en.wikipedia.org/wiki/Turbo_Vision">Turbo
+  * Vision</a> library.  Jexer's goal is to enable people to get up and
+  * running with minimum hassle and lots of polish.  A very quick "Hello
+  * World" application can be created as simply as this:
+  *
+  * <pre>
+  * {@code
+  * import jexer.TApplication;
+  *
+  * public class MyApplication extends TApplication {
+  *
+  *     public MyApplication() throws Exception {
+  *         super(BackendType.XTERM);
+  *
+  *         // Create standard menus for Tool, File, and Window.
+  *         addToolMenu();
+  *         addFileMenu();
+  *         addWindowMenu();
+  *     }
+  *
+  *     public static void main(String [] args) throws Exception {
+  *         MyApplication app = new MyApplication();
+  *         app.run();
+  *     }
+  * }
+  * }
+  * </pre>
+  */
+ package jexer;
index 0000000000000000000000000000000000000000,5c3a813f01fa52a6c5df93c8984849ed564a2290..5c3a813f01fa52a6c5df93c8984849ed564a2290
mode 000000,100644..100644
Binary files differ
index 0000000000000000000000000000000000000000,c9641940d04f7d6837bbaff9065ea52e66936e88..c9641940d04f7d6837bbaff9065ea52e66936e88
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,97 +1,97 @@@
+ Copyright (c) 2010 Dimitar Toshkov Zhekov,\r
+ with Reserved Font Name "Terminus Font".\r
\r
+ Copyright (c) 2011 Tilman Blumenbach,\r
+ with Reserved Font Name "Terminus (TTF)".\r
\r
+ This Font Software is licensed under the SIL Open Font License, Version 1.1.\r
+ This license is copied below, and is also available with a FAQ at:\r
+ http://scripts.sil.org/OFL\r
\r
\r
+ -----------------------------------------------------------\r
+ SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007\r
+ -----------------------------------------------------------\r
\r
+ PREAMBLE\r
+ The goals of the Open Font License (OFL) are to stimulate worldwide\r
+ development of collaborative font projects, to support the font creation\r
+ efforts of academic and linguistic communities, and to provide a free and\r
+ open framework in which fonts may be shared and improved in partnership\r
+ with others.\r
\r
+ The OFL allows the licensed fonts to be used, studied, modified and\r
+ redistributed freely as long as they are not sold by themselves. The\r
+ fonts, including any derivative works, can be bundled, embedded, \r
+ redistributed and/or sold with any software provided that any reserved\r
+ names are not used by derivative works. The fonts and derivatives,\r
+ however, cannot be released under any other type of license. The\r
+ requirement for fonts to remain under this license does not apply\r
+ to any document created using the fonts or their derivatives.\r
\r
+ DEFINITIONS\r
+ "Font Software" refers to the set of files released by the Copyright\r
+ Holder(s) under this license and clearly marked as such. This may\r
+ include source files, build scripts and documentation.\r
\r
+ "Reserved Font Name" refers to any names specified as such after the\r
+ copyright statement(s).\r
\r
+ "Original Version" refers to the collection of Font Software components as\r
+ distributed by the Copyright Holder(s).\r
\r
+ "Modified Version" refers to any derivative made by adding to, deleting,\r
+ or substituting -- in part or in whole -- any of the components of the\r
+ Original Version, by changing formats or by porting the Font Software to a\r
+ new environment.\r
\r
+ "Author" refers to any designer, engineer, programmer, technical\r
+ writer or other person who contributed to the Font Software.\r
\r
+ PERMISSION & CONDITIONS\r
+ Permission is hereby granted, free of charge, to any person obtaining\r
+ a copy of the Font Software, to use, study, copy, merge, embed, modify,\r
+ redistribute, and sell modified and unmodified copies of the Font\r
+ Software, subject to the following conditions:\r
\r
+ 1) Neither the Font Software nor any of its individual components,\r
+ in Original or Modified Versions, may be sold by itself.\r
\r
+ 2) Original or Modified Versions of the Font Software may be bundled,\r
+ redistributed and/or sold with any software, provided that each copy\r
+ contains the above copyright notice and this license. These can be\r
+ included either as stand-alone text files, human-readable headers or\r
+ in the appropriate machine-readable metadata fields within text or\r
+ binary files as long as those fields can be easily viewed by the user.\r
\r
+ 3) No Modified Version of the Font Software may use the Reserved Font\r
+ Name(s) unless explicit written permission is granted by the corresponding\r
+ Copyright Holder. This restriction only applies to the primary font name as\r
+ presented to the users.\r
\r
+ 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font\r
+ Software shall not be used to promote, endorse or advertise any\r
+ Modified Version, except to acknowledge the contribution(s) of the\r
+ Copyright Holder(s) and the Author(s) or with their explicit written\r
+ permission.\r
\r
+ 5) The Font Software, modified or unmodified, in part or in whole,\r
+ must be distributed entirely under this license, and must not be\r
+ distributed under any other license. The requirement for fonts to\r
+ remain under this license does not apply to any document created\r
+ using the Font Software.\r
\r
+ TERMINATION\r
+ This license becomes null and void if any of the above conditions are\r
+ not met.\r
\r
+ DISCLAIMER\r
+ THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,\r
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF\r
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT\r
+ OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE\r
+ COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,\r
+ INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL\r
+ DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING\r
+ FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM\r
+ OTHER DEALINGS IN THE FONT SOFTWARE.\r
index 0000000000000000000000000000000000000000,f4bb6b21d5e15cb4d78a223930e410ca30f4f284..f4bb6b21d5e15cb4d78a223930e410ca30f4f284
mode 000000,100644..100644
Binary files differ
index 0000000000000000000000000000000000000000,06700dedccafee66dd488c8f56a90b18dc71944d..06700dedccafee66dd488c8f56a90b18dc71944d
mode 000000,100644..100644
Binary files differ
index 0000000000000000000000000000000000000000,e80d0c06ce93e6f05e769601b6707dd9413a9e4a..e80d0c06ce93e6f05e769601b6707dd9413a9e4a
mode 000000,100644..100644
Binary files differ
index 0000000000000000000000000000000000000000,2abfef6635f3c1877fc733ee36ea8c67d01160b6..2abfef6635f3c1877fc733ee36ea8c67d01160b6
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,640 +1,640 @@@
+ /*
+  * Jexer - Java Text User Interface
+  *
+  * The MIT License (MIT)
+  *
+  * 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"),
+  * to deal in the Software without restriction, including without limitation
+  * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+  * and/or sell copies of the Software, and to permit persons to whom the
+  * Software is furnished to do so, subject to the following conditions:
+  *
+  * The above copyright notice and this permission notice shall be included in
+  * all copies or substantial portions of the Software.
+  *
+  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+  * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+  * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+  * DEALINGS IN THE SOFTWARE.
+  *
+  * @author Kevin Lamonte [kevin.lamonte@gmail.com]
+  * @version 1
+  */
+ package jexer.teditor;
+ import java.io.FileOutputStream;
+ import java.io.IOException;
+ import java.io.OutputStreamWriter;
+ import java.util.ArrayList;
+ import java.util.List;
+ import jexer.bits.CellAttributes;
+ /**
+  * A Document represents a text file, as a collection of lines.
+  */
+ public class Document {
+     // ------------------------------------------------------------------------
+     // Variables --------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * The list of lines.
+      */
+     private ArrayList<Line> lines = new ArrayList<Line>();
+     /**
+      * The current line number being edited.  Note that this is 0-based, the
+      * first line is line number 0.
+      */
+     private int lineNumber = 0;
+     /**
+      * The overwrite flag.  When true, characters overwrite data.
+      */
+     private boolean overwrite = false;
+     /**
+      * If true, the document has been edited.
+      */
+     private boolean dirty = false;
+     /**
+      * The default color for the TEditor class.
+      */
+     private CellAttributes defaultColor = null;
+     /**
+      * The text highlighter to use.
+      */
+     private Highlighter highlighter = new Highlighter();
+     // ------------------------------------------------------------------------
+     // Constructors -----------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Construct a new Document from an existing text string.
+      *
+      * @param str the text string
+      * @param defaultColor the color for unhighlighted text
+      */
+     public Document(final String str, final CellAttributes defaultColor) {
+         this.defaultColor = defaultColor;
+         // TODO: set different colors based on file extension
+         highlighter.setJavaColors();
+         String [] rawLines = str.split("\n");
+         for (int i = 0; i < rawLines.length; i++) {
+             lines.add(new Line(rawLines[i], this.defaultColor, highlighter));
+         }
+     }
+     // ------------------------------------------------------------------------
+     // Document ---------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Get the overwrite flag.
+      *
+      * @return true if addChar() overwrites data, false if it inserts
+      */
+     public boolean getOverwrite() {
+         return overwrite;
+     }
+     /**
+      * Get the dirty value.
+      *
+      * @return true if the buffer is dirty
+      */
+     public boolean isDirty() {
+         return dirty;
+     }
+     /**
+      * Save contents to file.
+      *
+      * @param filename file to save to
+      * @throws IOException if a java.io operation throws
+      */
+     public void saveToFilename(final String filename) throws IOException {
+         OutputStreamWriter output = null;
+         try {
+             output = new OutputStreamWriter(new FileOutputStream(filename),
+                 "UTF-8");
+             for (Line line: lines) {
+                 output.write(line.getRawString());
+                 output.write("\n");
+             }
+             dirty = false;
+         }
+         finally {
+             if (output != null) {
+                 output.close();
+             }
+         }
+     }
+     /**
+      * Set the overwrite flag.
+      *
+      * @param overwrite true if addChar() should overwrite data, false if it
+      * should insert
+      */
+     public void setOverwrite(final boolean overwrite) {
+         this.overwrite = overwrite;
+     }
+     /**
+      * Get the current line number being edited.
+      *
+      * @return the line number.  Note that this is 0-based: 0 is the first
+      * line.
+      */
+     public int getLineNumber() {
+         return lineNumber;
+     }
+     /**
+      * Get the current editing line.
+      *
+      * @return the line
+      */
+     public Line getCurrentLine() {
+         return lines.get(lineNumber);
+     }
+     /**
+      * Get a specific line by number.
+      *
+      * @param lineNumber the line number.  Note that this is 0-based: 0 is
+      * the first line.
+      * @return the line
+      */
+     public Line getLine(final int lineNumber) {
+         return lines.get(lineNumber);
+     }
+     /**
+      * Set the current line number being edited.
+      *
+      * @param n the line number.  Note that this is 0-based: 0 is the first
+      * line.
+      */
+     public void setLineNumber(final int n) {
+         if ((n < 0) || (n > lines.size())) {
+             throw new IndexOutOfBoundsException("Lines array size is " +
+                 lines.size() + ", requested index " + n);
+         }
+         lineNumber = n;
+     }
+     /**
+      * Get the current cursor position of the editing line.
+      *
+      * @return the cursor position
+      */
+     public int getCursor() {
+         return lines.get(lineNumber).getCursor();
+     }
+     /**
+      * Get the character at the current cursor position in the text.
+      *
+      * @return the character, or -1 if the cursor is at the end of the line
+      */
+     public int getChar() {
+         return lines.get(lineNumber).getChar();
+     }
+     /**
+      * Set the current cursor position of the editing line.  0-based.
+      *
+      * @param cursor the new cursor position
+      */
+     public void setCursor(final int cursor) {
+         if (cursor >= lines.get(lineNumber).getDisplayLength()) {
+             lines.get(lineNumber).end();
+         } else {
+             lines.get(lineNumber).setCursor(cursor);
+         }
+     }
+     /**
+      * Increment the line number by one.  If at the last line, do nothing.
+      *
+      * @return true if the editing line changed
+      */
+     public boolean down() {
+         if (lineNumber < lines.size() - 1) {
+             int x = lines.get(lineNumber).getCursor();
+             lineNumber++;
+             if (x >= lines.get(lineNumber).getDisplayLength()) {
+                 lines.get(lineNumber).end();
+             } else {
+                 lines.get(lineNumber).setCursor(x);
+             }
+             return true;
+         }
+         return false;
+     }
+     /**
+      * Increment the line number by n.  If n would go past the last line,
+      * increment only to the last line.
+      *
+      * @param n the number of lines to increment by
+      * @return true if the editing line changed
+      */
+     public boolean down(final int n) {
+         if (lineNumber < lines.size() - 1) {
+             int x = lines.get(lineNumber).getCursor();
+             lineNumber += n;
+             if (lineNumber > lines.size() - 1) {
+                 lineNumber = lines.size() - 1;
+             }
+             if (x >= lines.get(lineNumber).getDisplayLength()) {
+                 lines.get(lineNumber).end();
+             } else {
+                 lines.get(lineNumber).setCursor(x);
+             }
+             return true;
+         }
+         return false;
+     }
+     /**
+      * Decrement the line number by one.  If at the first line, do nothing.
+      *
+      * @return true if the editing line changed
+      */
+     public boolean up() {
+         if (lineNumber > 0) {
+             int x = lines.get(lineNumber).getCursor();
+             lineNumber--;
+             if (x >= lines.get(lineNumber).getDisplayLength()) {
+                 lines.get(lineNumber).end();
+             } else {
+                 lines.get(lineNumber).setCursor(x);
+             }
+             return true;
+         }
+         return false;
+     }
+     /**
+      * Decrement the line number by n.  If n would go past the first line,
+      * decrement only to the first line.
+      *
+      * @param n the number of lines to decrement by
+      * @return true if the editing line changed
+      */
+     public boolean up(final int n) {
+         if (lineNumber > 0) {
+             int x = lines.get(lineNumber).getCursor();
+             lineNumber -= n;
+             if (lineNumber < 0) {
+                 lineNumber = 0;
+             }
+             if (x >= lines.get(lineNumber).getDisplayLength()) {
+                 lines.get(lineNumber).end();
+             } else {
+                 lines.get(lineNumber).setCursor(x);
+             }
+             return true;
+         }
+         return false;
+     }
+     /**
+      * Decrement the cursor by one.  If at the first column on the first
+      * line, do nothing.
+      *
+      * @return true if the cursor position changed
+      */
+     public boolean left() {
+         if (!lines.get(lineNumber).left()) {
+             // We are on the leftmost column, wrap
+             if (up()) {
+                 end();
+             } else {
+                 return false;
+             }
+         }
+         return true;
+     }
+     /**
+      * Increment the cursor by one.  If at the last column on the last line,
+      * do nothing.
+      *
+      * @return true if the cursor position changed
+      */
+     public boolean right() {
+         if (!lines.get(lineNumber).right()) {
+             // We are on the rightmost column, wrap
+             if (down()) {
+                 home();
+             } else {
+                 return false;
+             }
+         }
+         return true;
+     }
+     /**
+      * Go back to the beginning of this word if in the middle, or the
+      * beginning of the previous word.
+      */
+     public void backwardsWord() {
+         // If at the beginning of a word already, push past it.
+         if ((getChar() != -1)
+             && (getRawLine().length() > 0)
+             && !Character.isSpace((char) getChar())
+         ) {
+             left();
+         }
+         // int line = lineNumber;
+         while ((getChar() == -1)
+             || (getRawLine().length() == 0)
+             || Character.isSpace((char) getChar())
+         ) {
+             if (left() == false) {
+                 return;
+             }
+         }
+         assert (getChar() != -1);
+         if (!Character.isSpace((char) getChar())
+             && (getRawLine().length() > 0)
+         ) {
+             // Advance until at the beginning of the document or a whitespace
+             // is encountered.
+             while (!Character.isSpace((char) getChar())) {
+                 int line = lineNumber;
+                 if (left() == false) {
+                     // End of document, bail out.
+                     return;
+                 }
+                 if (lineNumber != line) {
+                     // We wrapped a line.  Here that counts as whitespace.
+                     right();
+                     return;
+                 }
+             }
+         }
+         // We went one past the word, push back to the first character of
+         // that word.
+         right();
+         return;
+     }
+     /**
+      * Go to the beginning of the next word.
+      */
+     public void forwardsWord() {
+         int line = lineNumber;
+         while ((getChar() == -1)
+             || (getRawLine().length() == 0)
+         ) {
+             if (right() == false) {
+                 return;
+             }
+             if (lineNumber != line) {
+                 // We wrapped a line.  Here that counts as whitespace.
+                 if (!Character.isSpace((char) getChar())) {
+                     // We found a character immediately after the line.
+                     // Done!
+                     return;
+                 }
+                 // Still looking...
+                 line = lineNumber;
+             }
+         }
+         assert (getChar() != -1);
+         if (!Character.isSpace((char) getChar())
+             && (getRawLine().length() > 0)
+         ) {
+             // Advance until at the end of the document or a whitespace is
+             // encountered.
+             while (!Character.isSpace((char) getChar())) {
+                 line = lineNumber;
+                 if (right() == false) {
+                     // End of document, bail out.
+                     return;
+                 }
+                 if (lineNumber != line) {
+                     // We wrapped a line.  Here that counts as whitespace.
+                     if (!Character.isSpace((char) getChar())
+                         && (getRawLine().length() > 0)
+                     ) {
+                         // We found a character immediately after the line.
+                         // Done!
+                         return;
+                     }
+                     break;
+                 }
+             }
+         }
+         while ((getChar() == -1)
+             || (getRawLine().length() == 0)
+         ) {
+             if (right() == false) {
+                 return;
+             }
+             if (lineNumber != line) {
+                 // We wrapped a line.  Here that counts as whitespace.
+                 if (!Character.isSpace((char) getChar())) {
+                     // We found a character immediately after the line.
+                     // Done!
+                     return;
+                 }
+                 // Still looking...
+                 line = lineNumber;
+             }
+         }
+         assert (getChar() != -1);
+         if (Character.isSpace((char) getChar())) {
+             // Advance until at the end of the document or a non-whitespace
+             // is encountered.
+             while (Character.isSpace((char) getChar())) {
+                 if (right() == false) {
+                     // End of document, bail out.
+                     return;
+                 }
+             }
+             return;
+         }
+         // We wrapped the line to get here.
+         return;
+     }
+     /**
+      * Get the raw string that matches this line.
+      *
+      * @return the string
+      */
+     public String getRawLine() {
+         return lines.get(lineNumber).getRawString();
+     }
+     /**
+      * Go to the first column of this line.
+      *
+      * @return true if the cursor position changed
+      */
+     public boolean home() {
+         return lines.get(lineNumber).home();
+     }
+     /**
+      * Go to the last column of this line.
+      *
+      * @return true if the cursor position changed
+      */
+     public boolean end() {
+         return lines.get(lineNumber).end();
+     }
+     /**
+      * Delete the character under the cursor.
+      */
+     public void del() {
+         dirty = true;
+         int cursor = lines.get(lineNumber).getCursor();
+         if (cursor < lines.get(lineNumber).getDisplayLength() - 1) {
+             lines.get(lineNumber).del();
+         } else if (lineNumber < lines.size() - 2) {
+             // Join two lines
+             StringBuilder newLine = new StringBuilder(lines.
+                 get(lineNumber).getRawString());
+             newLine.append(lines.get(lineNumber + 1).getRawString());
+             lines.set(lineNumber, new Line(newLine.toString(),
+                     defaultColor, highlighter));
+             lines.get(lineNumber).setCursor(cursor);
+             lines.remove(lineNumber + 1);
+         }
+     }
+     /**
+      * Delete the character immediately preceeding the cursor.
+      */
+     public void backspace() {
+         dirty = true;
+         int cursor = lines.get(lineNumber).getCursor();
+         if (cursor > 0) {
+             lines.get(lineNumber).backspace();
+         } else if (lineNumber > 0) {
+             // Join two lines
+             lineNumber--;
+             String firstLine = lines.get(lineNumber).getRawString();
+             if (firstLine.length() > 0) {
+                 // Backspacing combining two lines
+                 StringBuilder newLine = new StringBuilder(firstLine);
+                 newLine.append(lines.get(lineNumber + 1).getRawString());
+                 lines.set(lineNumber, new Line(newLine.toString(),
+                         defaultColor, highlighter));
+                 lines.get(lineNumber).setCursor(firstLine.length());
+                 lines.remove(lineNumber + 1);
+             } else {
+                 // Backspacing an empty line
+                 lines.remove(lineNumber);
+                 lines.get(lineNumber).setCursor(0);
+             }
+         }
+     }
+     /**
+      * Split the current line into two, like pressing the enter key.
+      */
+     public void enter() {
+         dirty = true;
+         int cursor = lines.get(lineNumber).getRawCursor();
+         String original = lines.get(lineNumber).getRawString();
+         String firstLine = original.substring(0, cursor);
+         String secondLine = original.substring(cursor);
+         lines.add(lineNumber + 1, new Line(secondLine, defaultColor,
+                 highlighter));
+         lines.set(lineNumber, new Line(firstLine, defaultColor, highlighter));
+         lineNumber++;
+         lines.get(lineNumber).home();
+     }
+     /**
+      * Replace or insert a character at the cursor, depending on overwrite
+      * flag.
+      *
+      * @param ch the character to replace or insert
+      */
+     public void addChar(final int ch) {
+         dirty = true;
+         if (overwrite) {
+             lines.get(lineNumber).replaceChar(ch);
+         } else {
+             lines.get(lineNumber).addChar(ch);
+         }
+     }
+     /**
+      * Get a (shallow) copy of the list of lines.
+      *
+      * @return the list of lines
+      */
+     public List<Line> getLines() {
+         return new ArrayList<Line>(lines);
+     }
+     /**
+      * Get the number of lines.
+      *
+      * @return the number of lines
+      */
+     public int getLineCount() {
+         return lines.size();
+     }
+     /**
+      * Compute the maximum line length for this document.
+      *
+      * @return the number of cells needed to display the longest line
+      */
+     public int getLineLengthMax() {
+         int n = 0;
+         for (Line line : lines) {
+             if (line.getDisplayLength() > n) {
+                 n = line.getDisplayLength();
+             }
+         }
+         return n;
+     }
+     /**
+      * Get the current line length.
+      *
+      * @return the number of cells needed to display the current line
+      */
+     public int getLineLength() {
+         return lines.get(lineNumber).getDisplayLength();
+     }
+ }
index 0000000000000000000000000000000000000000,a48419455e541697e12da7dfd81ebb8db52d7e9e..a48419455e541697e12da7dfd81ebb8db52d7e9e
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,146 +1,146 @@@
+ /*
+  * Jexer - Java Text User Interface
+  *
+  * The MIT License (MIT)
+  *
+  * 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"),
+  * to deal in the Software without restriction, including without limitation
+  * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+  * and/or sell copies of the Software, and to permit persons to whom the
+  * Software is furnished to do so, subject to the following conditions:
+  *
+  * The above copyright notice and this permission notice shall be included in
+  * all copies or substantial portions of the Software.
+  *
+  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+  * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+  * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+  * DEALINGS IN THE SOFTWARE.
+  *
+  * @author Kevin Lamonte [kevin.lamonte@gmail.com]
+  * @version 1
+  */
+ package jexer.teditor;
+ import java.util.SortedMap;
+ import java.util.TreeMap;
+ import jexer.bits.CellAttributes;
+ import jexer.bits.Color;
+ /**
+  * Highlighter provides color choices for certain text strings.
+  */
+ public class Highlighter {
+     // ------------------------------------------------------------------------
+     // Variables --------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * The highlighter colors.
+      */
+     private SortedMap<String, CellAttributes> colors;
+     // ------------------------------------------------------------------------
+     // Constructors -----------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Public constructor sets the theme to the default.
+      */
+     public Highlighter() {
+         colors = new TreeMap<String, CellAttributes>();
+     }
+     // ------------------------------------------------------------------------
+     // Highlighter ------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * See if this is a character that should split a word.
+      *
+      * @param ch the character
+      * @return true if the word should be split
+      */
+     public boolean shouldSplit(final int ch) {
+         // For now, split on punctuation
+         String punctuation = "'\"\\<>{}[]!@#$%^&*();:.,-+/*?";
+         if (ch < 0x100) {
+             if (punctuation.indexOf((char) ch) != -1) {
+                 return true;
+             }
+         }
+         return false;
+     }
+     /**
+      * Retrieve the CellAttributes for a named theme color.
+      *
+      * @param name theme color name, e.g. "twindow.border"
+      * @return color associated with name, e.g. bold yellow on blue
+      */
+     public CellAttributes getColor(final String name) {
+         CellAttributes attr = (CellAttributes) colors.get(name);
+         return attr;
+     }
+     /**
+      * Sets to defaults that resemble the Borland IDE colors.
+      */
+     public void setJavaColors() {
+         CellAttributes color;
+         String [] keywords = {
+             "boolean", "byte", "short", "int", "long", "char", "float",
+             "double", "void", "new",
+             "static", "final", "volatile", "synchronized", "abstract",
+             "public", "private", "protected",
+             "class", "interface", "extends", "implements",
+             "if", "else", "do", "while", "for", "break", "continue",
+             "switch", "case", "default",
+         };
+         color = new CellAttributes();
+         color.setForeColor(Color.WHITE);
+         color.setBackColor(Color.BLUE);
+         color.setBold(true);
+         for (String str: keywords) {
+             colors.put(str, color);
+         }
+         String [] operators = {
+             "[", "]", "(", ")", "{", "}",
+             "*", "-", "+", "/", "=", "%",
+             "^", "&", "!", "<<", ">>", "<<<", ">>>",
+             "&&", "||",
+             ">", "<", ">=", "<=", "!=", "==",
+             ",", ";", ".", "?", ":",
+         };
+         color = new CellAttributes();
+         color.setForeColor(Color.CYAN);
+         color.setBackColor(Color.BLUE);
+         color.setBold(true);
+         for (String str: operators) {
+             colors.put(str, color);
+         }
+         String [] packageKeywords = {
+             "package", "import",
+         };
+         color = new CellAttributes();
+         color.setForeColor(Color.GREEN);
+         color.setBackColor(Color.BLUE);
+         color.setBold(true);
+         for (String str: packageKeywords) {
+             colors.put(str, color);
+         }
+     }
+ }
index 0000000000000000000000000000000000000000,7cd5febabee8462f6c51bc66e886903795aff698..7cd5febabee8462f6c51bc66e886903795aff698
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,365 +1,365 @@@
+ /*
+  * Jexer - Java Text User Interface
+  *
+  * The MIT License (MIT)
+  *
+  * 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"),
+  * to deal in the Software without restriction, including without limitation
+  * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+  * and/or sell copies of the Software, and to permit persons to whom the
+  * Software is furnished to do so, subject to the following conditions:
+  *
+  * The above copyright notice and this permission notice shall be included in
+  * all copies or substantial portions of the Software.
+  *
+  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+  * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+  * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+  * DEALINGS IN THE SOFTWARE.
+  *
+  * @author Kevin Lamonte [kevin.lamonte@gmail.com]
+  * @version 1
+  */
+ package jexer.teditor;
+ import java.util.ArrayList;
+ import java.util.List;
+ import jexer.bits.CellAttributes;
+ import jexer.bits.StringUtils;
+ /**
+  * A Line represents a single line of text on the screen, as a collection of
+  * words.
+  */
+ public class Line {
+     // ------------------------------------------------------------------------
+     // Variables --------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * The list of words.
+      */
+     private ArrayList<Word> words = new ArrayList<Word>();
+     /**
+      * The default color for the TEditor class.
+      */
+     private CellAttributes defaultColor = null;
+     /**
+      * The text highlighter to use.
+      */
+     private Highlighter highlighter = null;
+     /**
+      * The current edition position on this line.
+      */
+     private int position = 0;
+     /**
+      * The current editing position screen column number.
+      */
+     private int screenPosition = 0;
+     /**
+      * The raw text of this line, what is passed to Word to determine
+      * highlighting behavior.
+      */
+     private StringBuilder rawText;
+     // ------------------------------------------------------------------------
+     // Constructors -----------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Construct a new Line from an existing text string, and highlight
+      * certain strings.
+      *
+      * @param str the text string
+      * @param defaultColor the color for unhighlighted text
+      * @param highlighter the highlighter to use
+      */
+     public Line(final String str, final CellAttributes defaultColor,
+         final Highlighter highlighter) {
+         this.defaultColor = defaultColor;
+         this.highlighter = highlighter;
+         this.rawText = new StringBuilder(str);
+         scanLine();
+     }
+     /**
+      * Construct a new Line from an existing text string.
+      *
+      * @param str the text string
+      * @param defaultColor the color for unhighlighted text
+      */
+     public Line(final String str, final CellAttributes defaultColor) {
+         this(str, defaultColor, null);
+     }
+     // ------------------------------------------------------------------------
+     // Line -------------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Get a (shallow) copy of the words in this line.
+      *
+      * @return a copy of the word list
+      */
+     public List<Word> getWords() {
+         return new ArrayList<Word>(words);
+     }
+     /**
+      * Get the current cursor position in the text.
+      *
+      * @return the cursor position
+      */
+     public int getRawCursor() {
+         return position;
+     }
+     /**
+      * Get the current cursor position on screen.
+      *
+      * @return the cursor position
+      */
+     public int getCursor() {
+         return screenPosition;
+     }
+     /**
+      * Set the current cursor position.
+      *
+      * @param cursor the new cursor position
+      */
+     public void setCursor(final int cursor) {
+         if ((cursor < 0)
+             || ((cursor >= getDisplayLength())
+                 && (getDisplayLength() > 0))
+         ) {
+             throw new IndexOutOfBoundsException("Max length is " +
+                 getDisplayLength() + ", requested position " + cursor);
+         }
+         screenPosition = cursor;
+         position = screenToTextPosition(screenPosition);
+     }
+     /**
+      * Get the character at the current cursor position in the text.
+      *
+      * @return the character, or -1 if the cursor is at the end of the line
+      */
+     public int getChar() {
+         if (position == rawText.length()) {
+             return -1;
+         }
+         return rawText.codePointAt(position);
+     }
+     /**
+      * Get the on-screen display length.
+      *
+      * @return the number of cells needed to display this line
+      */
+     public int getDisplayLength() {
+         int n = StringUtils.width(rawText.toString());
+         if (n > 0) {
+             // If we have any visible characters, add one to the display so
+             // that the position is immediately after the data.
+             return n + 1;
+         }
+         return n;
+     }
+     /**
+      * Get the raw string that matches this line.
+      *
+      * @return the string
+      */
+     public String getRawString() {
+         return rawText.toString();
+     }
+     /**
+      * Scan rawText and make words out of it.
+      */
+     private void scanLine() {
+         words.clear();
+         Word word = new Word(this.defaultColor, this.highlighter);
+         words.add(word);
+         for (int i = 0; i < rawText.length();) {
+             int ch = rawText.codePointAt(i);
+             i += Character.charCount(ch);
+             Word newWord = word.addChar(ch);
+             if (newWord != word) {
+                 words.add(newWord);
+                 word = newWord;
+             }
+         }
+         for (Word w: words) {
+             w.applyHighlight();
+         }
+     }
+     /**
+      * Decrement the cursor by one.  If at the first column, do nothing.
+      *
+      * @return true if the cursor position changed
+      */
+     public boolean left() {
+         if (position == 0) {
+             return false;
+         }
+         screenPosition -= StringUtils.width(rawText.codePointBefore(position));
+         position -= Character.charCount(rawText.codePointBefore(position));
+         return true;
+     }
+     /**
+      * Increment the cursor by one.  If at the last column, do nothing.
+      *
+      * @return true if the cursor position changed
+      */
+     public boolean right() {
+         if (getDisplayLength() == 0) {
+             return false;
+         }
+         if (position == getDisplayLength() - 1) {
+             return false;
+         }
+         if (position < rawText.length()) {
+             screenPosition += StringUtils.width(rawText.codePointAt(position));
+             position += Character.charCount(rawText.codePointAt(position));
+         }
+         assert (position <= rawText.length());
+         return true;
+     }
+     /**
+      * Go to the first column of this line.
+      *
+      * @return true if the cursor position changed
+      */
+     public boolean home() {
+         if (position > 0) {
+             position = 0;
+             screenPosition = 0;
+             return true;
+         }
+         return false;
+     }
+     /**
+      * Go to the last column of this line.
+      *
+      * @return true if the cursor position changed
+      */
+     public boolean end() {
+         if (position != getDisplayLength() - 1) {
+             position = rawText.length();
+             screenPosition = StringUtils.width(rawText.toString());
+             return true;
+         }
+         return false;
+     }
+     /**
+      * Delete the character under the cursor.
+      */
+     public void del() {
+         assert (words.size() > 0);
+         if (position < getDisplayLength()) {
+             int n = Character.charCount(rawText.codePointAt(position));
+             for (int i = 0; i < n; i++) {
+                 rawText.deleteCharAt(position);
+             }
+         }
+         // Re-scan the line to determine the new word boundaries.
+         scanLine();
+     }
+     /**
+      * Delete the character immediately preceeding the cursor.
+      */
+     public void backspace() {
+         if (left()) {
+             del();
+         }
+     }
+     /**
+      * Insert a character at the cursor.
+      *
+      * @param ch the character to insert
+      */
+     public void addChar(final int ch) {
+         if (position < getDisplayLength() - 1) {
+             rawText.insert(position, Character.toChars(ch));
+         } else {
+             rawText.append(Character.toChars(ch));
+         }
+         position += Character.charCount(ch);
+         screenPosition += StringUtils.width(ch);
+         scanLine();
+     }
+     /**
+      * Replace a character at the cursor.
+      *
+      * @param ch the character to replace
+      */
+     public void replaceChar(final int ch) {
+         if (position < getDisplayLength() - 1) {
+             // Replace character
+             String oldText = rawText.toString();
+             rawText = new StringBuilder(oldText.substring(0, position));
+             rawText.append(Character.toChars(ch));
+             rawText.append(oldText.substring(position + 1));
+             screenPosition += StringUtils.width(rawText.codePointAt(position));
+             position += Character.charCount(ch);
+         } else {
+             rawText.append(Character.toChars(ch));
+             position += Character.charCount(ch);
+             screenPosition += StringUtils.width(ch);
+         }
+         scanLine();
+     }
+     /**
+      * Determine string position from screen position.
+      *
+      * @param screenPosition the position on screen
+      * @return the equivalent position in text
+      */
+     protected int screenToTextPosition(final int screenPosition) {
+         if (screenPosition == 0) {
+             return 0;
+         }
+         int n = 0;
+         for (int i = 0; i < rawText.length(); i++) {
+             n += StringUtils.width(rawText.codePointAt(i));
+             if (n >= screenPosition) {
+                 return i + 1;
+             }
+         }
+         // screenPosition exceeds the available text length.
+         throw new IndexOutOfBoundsException("screenPosition " + screenPosition +
+             " exceeds available text length " + rawText.length());
+     }
+ }
index 0000000000000000000000000000000000000000,eada29cff83ed8b1c61c59d1741b5646d1f645b8..eada29cff83ed8b1c61c59d1741b5646d1f645b8
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,226 +1,226 @@@
+ /*
+  * Jexer - Java Text User Interface
+  *
+  * The MIT License (MIT)
+  *
+  * 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"),
+  * to deal in the Software without restriction, including without limitation
+  * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+  * and/or sell copies of the Software, and to permit persons to whom the
+  * Software is furnished to do so, subject to the following conditions:
+  *
+  * The above copyright notice and this permission notice shall be included in
+  * all copies or substantial portions of the Software.
+  *
+  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+  * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+  * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+  * DEALINGS IN THE SOFTWARE.
+  *
+  * @author Kevin Lamonte [kevin.lamonte@gmail.com]
+  * @version 1
+  */
+ package jexer.teditor;
+ import jexer.bits.CellAttributes;
+ import jexer.bits.StringUtils;
+ /**
+  * A Word represents text that was entered by the user.  It can be either
+  * whitespace or non-whitespace.
+  *
+  * Very dumb highlighting is supported, it has no sense of parsing (not even
+  * comments).  For now this only highlights some Java keywords and
+  * puctuation.
+  */
+ public class Word {
+     // ------------------------------------------------------------------------
+     // Variables --------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * The color to render this word as on screen.
+      */
+     private CellAttributes color = new CellAttributes();
+     /**
+      * The default color for the TEditor class.
+      */
+     private CellAttributes defaultColor = null;
+     /**
+      * The text highlighter to use.
+      */
+     private Highlighter highlighter = null;
+     /**
+      * The actual text of this word.  Average word length is 6 characters,
+      * with a lot of shorter ones, so start with 3.
+      */
+     private StringBuilder text = new StringBuilder(3);
+     // ------------------------------------------------------------------------
+     // Constructors -----------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Construct a word with one character.
+      *
+      * @param ch the first character of the word
+      * @param defaultColor the color for unhighlighted text
+      * @param highlighter the highlighter to use
+      */
+     public Word(final int ch, final CellAttributes defaultColor,
+         final Highlighter highlighter) {
+         this.defaultColor = defaultColor;
+         this.highlighter = highlighter;
+         text.append(Character.toChars(ch));
+     }
+     /**
+      * Construct a word with an empty string.
+      *
+      * @param defaultColor the color for unhighlighted text
+      * @param highlighter the highlighter to use
+      */
+     public Word(final CellAttributes defaultColor,
+         final Highlighter highlighter) {
+         this.defaultColor = defaultColor;
+         this.highlighter = highlighter;
+     }
+     // ------------------------------------------------------------------------
+     // Word -------------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Get the color used to display this word on screen.
+      *
+      * @return the color
+      */
+     public CellAttributes getColor() {
+         return new CellAttributes(color);
+     }
+     /**
+      * Set the color used to display this word on screen.
+      *
+      * @param color the color
+      */
+     public void setColor(final CellAttributes color) {
+         color.setTo(color);
+     }
+     /**
+      * Get the text to display.
+      *
+      * @return the text
+      */
+     public String getText() {
+         return text.toString();
+     }
+     /**
+      * Get the on-screen display length.
+      *
+      * @return the number of cells needed to display this word
+      */
+     public int getDisplayLength() {
+         // For now, just use the text length.  In the future, this will be a
+         // grapheme count.
+         // TODO: figure out how to handle the tab character.  Do we have a
+         // global tab stops list and current word position?
+         return StringUtils.width(text.toString());
+     }
+     /**
+      * See if this is a whitespace word.  Note that empty string is
+      * considered whitespace.
+      *
+      * @return true if this word is whitespace
+      */
+     public boolean isWhitespace() {
+         if (text.length() == 0) {
+             return true;
+         }
+         if (Character.isWhitespace(text.charAt(0))) {
+             return true;
+         }
+         return false;
+     }
+     /**
+      * Perform highlighting.
+      */
+     public void applyHighlight() {
+         color.setTo(defaultColor);
+         if (highlighter == null) {
+             return;
+         }
+         String key = text.toString();
+         CellAttributes newColor = highlighter.getColor(key);
+         if (newColor != null) {
+             color.setTo(newColor);
+         }
+     }
+     /**
+      * Add a character to this word.  If this is a whitespace character
+      * adding to a non-whitespace word, create a new word and return that;
+      * similarly if this a non-whitespace character adding to a whitespace
+      * word, create a new word and return that.  Note package private access:
+      * this is only called by Line to figure out highlighting boundaries.
+      *
+      * @param ch the new character to add
+      * @return either this word (if it was added), or a new word that
+      * contains ch
+      */
+     public Word addChar(final int ch) {
+         if (text.length() == 0) {
+             text.append(Character.toChars(ch));
+             return this;
+         }
+         // Give the highlighter the option to split here.
+         if (highlighter != null) {
+             if (highlighter.shouldSplit(ch)
+                 || highlighter.shouldSplit(text.charAt(0))
+             ) {
+                 Word newWord = new Word(ch, defaultColor, highlighter);
+                 return newWord;
+             }
+         }
+         // Highlighter didn't care, so split at whitespace.
+         if (Character.isWhitespace(text.charAt(0))
+             && Character.isWhitespace(ch)
+         ) {
+             // Adding to a whitespace word, keep at it.
+             text.append(Character.toChars(ch));
+             return this;
+         }
+         if (!Character.isWhitespace(text.charAt(0))
+             && !Character.isWhitespace(ch)
+         ) {
+             // Adding to a non-whitespace word, keep at it.
+             text.append(Character.toChars(ch));
+             return this;
+         }
+         // Switching from whitespace to non-whitespace or vice versa, so
+         // split here.
+         Word newWord = new Word(ch, defaultColor, highlighter);
+         return newWord;
+     }
+ }
index 0000000000000000000000000000000000000000,8bf5199b4c596280dae88b54a6e196ffe230acff..8bf5199b4c596280dae88b54a6e196ffe230acff
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,33 +1,33 @@@
+ /*
+  * Jexer - Java Text User Interface
+  *
+  * The MIT License (MIT)
+  *
+  * 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"),
+  * to deal in the Software without restriction, including without limitation
+  * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+  * and/or sell copies of the Software, and to permit persons to whom the
+  * Software is furnished to do so, subject to the following conditions:
+  *
+  * The above copyright notice and this permission notice shall be included in
+  * all copies or substantial portions of the Software.
+  *
+  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+  * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+  * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+  * DEALINGS IN THE SOFTWARE.
+  *
+  * @author Kevin Lamonte [kevin.lamonte@gmail.com]
+  * @version 1
+  */
+ /**
+  * A basic text editor backend supporting word highlighting.
+  */
+ package jexer.teditor;
index 0000000000000000000000000000000000000000,bca81bb701a8d82482c39fffa569da6bc33dba0a..bca81bb701a8d82482c39fffa569da6bc33dba0a
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,381 +1,381 @@@
+ /*
+  * Jexer - Java Text User Interface
+  *
+  * The MIT License (MIT)
+  *
+  * 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"),
+  * to deal in the Software without restriction, including without limitation
+  * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+  * and/or sell copies of the Software, and to permit persons to whom the
+  * Software is furnished to do so, subject to the following conditions:
+  *
+  * The above copyright notice and this permission notice shall be included in
+  * all copies or substantial portions of the Software.
+  *
+  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+  * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+  * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+  * DEALINGS IN THE SOFTWARE.
+  *
+  * @author Kevin Lamonte [kevin.lamonte@gmail.com]
+  * @version 1
+  */
+ package jexer.tterminal;
+ /**
+  * This class contains a collection of the DEC VT100 and VT220 character set
+  * mappings into Unicode.
+  */
+ public final class DECCharacterSets {
+     // ------------------------------------------------------------------------
+     // Constants --------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * US - Normal "international" (ASCII).
+      */
+     public static final char [] US_ASCII = {
+         0x0000, 0x0001, 0x0002, 0x0003, 0x0004, 0x0005, 0x0006, 0x0007,
+         0x0008, 0x0009, 0x000A, 0x000B, 0x000C, 0x000D, 0x000E, 0x000F,
+         0x0010, 0x0011, 0x0012, 0x0013, 0x0014, 0x0015, 0x0016, 0x0017,
+         0x0018, 0x0019, 0x001A, 0x001B, 0x001C, 0x001D, 0x001E, 0x001F,
+         0x0020, 0x0021, 0x0022, 0x0023, 0x0024, 0x0025, 0x0026, 0x0027,
+         0x0028, 0x0029, 0x002A, 0x002B, 0x002C, 0x002D, 0x002E, 0x002F,
+         0x0030, 0x0031, 0x0032, 0x0033, 0x0034, 0x0035, 0x0036, 0x0037,
+         0x0038, 0x0039, 0x003A, 0x003B, 0x003C, 0x003D, 0x003E, 0x003F,
+         0x0040, 0x0041, 0x0042, 0x0043, 0x0044, 0x0045, 0x0046, 0x0047,
+         0x0048, 0x0049, 0x004A, 0x004B, 0x004C, 0x004D, 0x004E, 0x004F,
+         0x0050, 0x0051, 0x0052, 0x0053, 0x0054, 0x0055, 0x0056, 0x0057,
+         0x0058, 0x0059, 0x005A, 0x005B, 0x005C, 0x005D, 0x005E, 0x005F,
+         0x0060, 0x0061, 0x0062, 0x0063, 0x0064, 0x0065, 0x0066, 0x0067,
+         0x0068, 0x0069, 0x006A, 0x006B, 0x006C, 0x006D, 0x006E, 0x006F,
+         0x0070, 0x0071, 0x0072, 0x0073, 0x0074, 0x0075, 0x0076, 0x0077,
+         0x0078, 0x0079, 0x007A, 0x007B, 0x007C, 0x007D, 0x007E, 0x0020
+     };
+     /**
+      * DEC Supplemental Graphics (VT100 drawing characters).
+      */
+     public static final char [] SPECIAL_GRAPHICS = {
+         0x0000, 0x0001, 0x0002, 0x0003, 0x0004, 0x0005, 0x0006, 0x0007,
+         0x0008, 0x0009, 0x000A, 0x000B, 0x000C, 0x000D, 0x000E, 0x000F,
+         0x0010, 0x0011, 0x0012, 0x0013, 0x0014, 0x0015, 0x0016, 0x0017,
+         0x0018, 0x0019, 0x001A, 0x001B, 0x001C, 0x001D, 0x001E, 0x001F,
+         0x0020, 0x0021, 0x0022, 0x0023, 0x0024, 0x0025, 0x0026, 0x0027,
+         0x0028, 0x0029, 0x002A, 0x002B, 0x002C, 0x002D, 0x002E, 0x002F,
+         0x0030, 0x0031, 0x0032, 0x0033, 0x0034, 0x0035, 0x0036, 0x0037,
+         0x0038, 0x0039, 0x003A, 0x003B, 0x003C, 0x003D, 0x003E, 0x003F,
+         0x0040, 0x0041, 0x0042, 0x0043, 0x0044, 0x0045, 0x0046, 0x0047,
+         0x0048, 0x0049, 0x004A, 0x004B, 0x004C, 0x004D, 0x004E, 0x004F,
+         0x0050, 0x0051, 0x0052, 0x0053, 0x0054, 0x0055, 0x0056, 0x0057,
+         0x0058, 0x0059, 0x005A, 0x005B, 0x005C, 0x005D, 0x005E, 0x005F,
+         0x2666, 0x2592, 0x2409, 0x240C, 0x240D, 0x240A, 0x00B0, 0x00B1,
+         0x2424, 0x240B, 0x2518, 0x2510, 0x250C, 0x2514, 0x253C, 0x23BA,
+         0x23BB, 0x2500, 0x23BC, 0x23BD, 0x251C, 0x2524, 0x2534, 0x252C,
+         0x2502, 0x2264, 0x2265, 0x03C0, 0x2260, 0x00A3, 0x00B7, 0x0020
+     };
+     /**
+      * Dec Supplemental (DEC multinational).
+      */
+     public static final char [] DEC_SUPPLEMENTAL = {
+         0x0080, 0x0081, 0x0082, 0x0083, 0x0084, 0x0085, 0x0086, 0x0087,
+         0x0088, 0x0089, 0x008A, 0x008B, 0x008C, 0x008D, 0x008E, 0x008F,
+         0x0090, 0x0091, 0x0092, 0x0093, 0x0094, 0x0095, 0x0096, 0x0097,
+         0x0098, 0x0099, 0x009A, 0x009B, 0x009C, 0x009D, 0x009E, 0x009F,
+         0x0020, 0x00A1, 0x00A2, 0x00A3, 0x00A8, 0x00A5, 0x0020, 0x00A7,
+         0x00A4, 0x00A9, 0x00AA, 0x00AB, 0x0020, 0x0020, 0x0020, 0x0020,
+         0x00B0, 0x00B1, 0x00B2, 0x00B3, 0x0020, 0x00B5, 0x00B6, 0x00B7,
+         0x0020, 0x00B9, 0x00BA, 0x00BB, 0x00BC, 0x00BD, 0x0020, 0x00BF,
+         0x00C0, 0x00C1, 0x00C2, 0x00C3, 0x00C4, 0x00C5, 0x00C6, 0x00C7,
+         0x00C8, 0x00C9, 0x00CA, 0x00CB, 0x00CC, 0x00CD, 0x00CE, 0x00CF,
+         0x0020, 0x00D1, 0x00D2, 0x00D3, 0x00D4, 0x00D5, 0x00D6, 0x0157,
+         0x00D8, 0x00D9, 0x00DA, 0x00DB, 0x00DC, 0x0178, 0x0020, 0x00DF,
+         0x00E0, 0x00E1, 0x00E2, 0x00E3, 0x00E4, 0x00E5, 0x00E6, 0x00E7,
+         0x00E8, 0x00E9, 0x00EA, 0x00EB, 0x00EC, 0x00ED, 0x00EE, 0x00EF,
+         0x0020, 0x00F1, 0x00F2, 0x00F3, 0x00F4, 0x00F5, 0x00F6, 0x0153,
+         0x00F8, 0x00F9, 0x00FA, 0x00FB, 0x00FC, 0x00FF, 0x0020, 0x0020
+     };
+     /**
+      * UK.
+      */
+     public static final char [] UK = {
+         0x0000, 0x0001, 0x0002, 0x0003, 0x0004, 0x0005, 0x0006, 0x0007,
+         0x0008, 0x0009, 0x000A, 0x000B, 0x000C, 0x000D, 0x000E, 0x000F,
+         0x0010, 0x0011, 0x0012, 0x0013, 0x0014, 0x0015, 0x0016, 0x0017,
+         0x0018, 0x0019, 0x001A, 0x001B, 0x001C, 0x001D, 0x001E, 0x001F,
+         0x0020, 0x0021, 0x0022, 0x00A3, 0x0024, 0x0025, 0x0026, 0x0027,
+         0x0028, 0x0029, 0x002A, 0x002B, 0x002C, 0x002D, 0x002E, 0x002F,
+         0x0030, 0x0031, 0x0032, 0x0033, 0x0034, 0x0035, 0x0036, 0x0037,
+         0x0038, 0x0039, 0x003A, 0x003B, 0x003C, 0x003D, 0x003E, 0x003F,
+         0x0040, 0x0041, 0x0042, 0x0043, 0x0044, 0x0045, 0x0046, 0x0047,
+         0x0048, 0x0049, 0x004A, 0x004B, 0x004C, 0x004D, 0x004E, 0x004F,
+         0x0050, 0x0051, 0x0052, 0x0053, 0x0054, 0x0055, 0x0056, 0x0057,
+         0x0058, 0x0059, 0x005A, 0x005B, 0x005C, 0x005D, 0x005E, 0x005F,
+         0x0060, 0x0061, 0x0062, 0x0063, 0x0064, 0x0065, 0x0066, 0x0067,
+         0x0068, 0x0069, 0x006A, 0x006B, 0x006C, 0x006D, 0x006E, 0x006F,
+         0x0070, 0x0071, 0x0072, 0x0073, 0x0074, 0x0075, 0x0076, 0x0077,
+         0x0078, 0x0079, 0x007A, 0x007B, 0x007C, 0x007D, 0x007E, 0x0020
+     };
+     /**
+      * DUTCH.
+      */
+     public static final char [] NL = {
+         0x0000, 0x0001, 0x0002, 0x0003, 0x0004, 0x0005, 0x0006, 0x0007,
+         0x0008, 0x0009, 0x000A, 0x000B, 0x000C, 0x000D, 0x000E, 0x000F,
+         0x0010, 0x0011, 0x0012, 0x0013, 0x0014, 0x0015, 0x0016, 0x0017,
+         0x0018, 0x0019, 0x001A, 0x001B, 0x001C, 0x001D, 0x001E, 0x001F,
+         0x0020, 0x0021, 0x0022, 0x00A3, 0x0024, 0x0025, 0x0026, 0x0027,
+         0x0028, 0x0029, 0x002A, 0x002B, 0x002C, 0x002D, 0x002E, 0x002F,
+         0x0030, 0x0031, 0x0032, 0x0033, 0x0034, 0x0035, 0x0036, 0x0037,
+         0x0038, 0x0039, 0x003A, 0x003B, 0x003C, 0x003D, 0x003E, 0x003F,
+         0x00BE, 0x0041, 0x0042, 0x0043, 0x0044, 0x0045, 0x0046, 0x0047,
+         0x0048, 0x0049, 0x004A, 0x004B, 0x004C, 0x004D, 0x004E, 0x004F,
+         0x0050, 0x0051, 0x0052, 0x0053, 0x0054, 0x0055, 0x0056, 0x0057,
+         0x0058, 0x0059, 0x005A, 0x0133, 0x00BD, 0x007C, 0x005E, 0x005F,
+         0x0060, 0x0061, 0x0062, 0x0063, 0x0064, 0x0065, 0x0066, 0x0067,
+         0x0068, 0x0069, 0x006A, 0x006B, 0x006C, 0x006D, 0x006E, 0x006F,
+         0x0070, 0x0071, 0x0072, 0x0073, 0x0074, 0x0075, 0x0076, 0x0077,
+         0x0078, 0x0079, 0x007A, 0x00A8, 0x0066, 0x00BC, 0x00B4, 0x0020
+     };
+     /**
+      * FINNISH.
+      */
+     public static final char [] FI = {
+         0x0000, 0x0001, 0x0002, 0x0003, 0x0004, 0x0005, 0x0006, 0x0007,
+         0x0008, 0x0009, 0x000A, 0x000B, 0x000C, 0x000D, 0x000E, 0x000F,
+         0x0010, 0x0011, 0x0012, 0x0013, 0x0014, 0x0015, 0x0016, 0x0017,
+         0x0018, 0x0019, 0x001A, 0x001B, 0x001C, 0x001D, 0x001E, 0x001F,
+         0x0020, 0x0021, 0x0022, 0x0023, 0x0024, 0x0025, 0x0026, 0x0027,
+         0x0028, 0x0029, 0x002A, 0x002B, 0x002C, 0x002D, 0x002E, 0x002F,
+         0x0030, 0x0031, 0x0032, 0x0033, 0x0034, 0x0035, 0x0036, 0x0037,
+         0x0038, 0x0039, 0x003A, 0x003B, 0x003C, 0x003D, 0x003E, 0x003F,
+         0x0040, 0x0041, 0x0042, 0x0043, 0x0044, 0x0045, 0x0046, 0x0047,
+         0x0048, 0x0049, 0x004A, 0x004B, 0x004C, 0x004D, 0x004E, 0x004F,
+         0x0050, 0x0051, 0x0052, 0x0053, 0x0054, 0x0055, 0x0056, 0x0057,
+         0x0058, 0x0059, 0x005A, 0x00C4, 0x00D6, 0x00C5, 0x00DC, 0x005F,
+         0x00E9, 0x0061, 0x0062, 0x0063, 0x0064, 0x0065, 0x0066, 0x0067,
+         0x0068, 0x0069, 0x006A, 0x006B, 0x006C, 0x006D, 0x006E, 0x006F,
+         0x0070, 0x0071, 0x0072, 0x0073, 0x0074, 0x0075, 0x0076, 0x0077,
+         0x0078, 0x0079, 0x007A, 0x00E4, 0x00F6, 0x00E5, 0x00FC, 0x0020
+     };
+     /**
+      * FRENCH.
+      */
+     public static final char [] FR = {
+         0x0000, 0x0001, 0x0002, 0x0003, 0x0004, 0x0005, 0x0006, 0x0007,
+         0x0008, 0x0009, 0x000A, 0x000B, 0x000C, 0x000D, 0x000E, 0x000F,
+         0x0010, 0x0011, 0x0012, 0x0013, 0x0014, 0x0015, 0x0016, 0x0017,
+         0x0018, 0x0019, 0x001A, 0x001B, 0x001C, 0x001D, 0x001E, 0x001F,
+         0x0020, 0x0021, 0x0022, 0x00A3, 0x0024, 0x0025, 0x0026, 0x0027,
+         0x0028, 0x0029, 0x002A, 0x002B, 0x002C, 0x002D, 0x002E, 0x002F,
+         0x0030, 0x0031, 0x0032, 0x0033, 0x0034, 0x0035, 0x0036, 0x0037,
+         0x0038, 0x0039, 0x003A, 0x003B, 0x003C, 0x003D, 0x003E, 0x003F,
+         0x00E0, 0x0041, 0x0042, 0x0043, 0x0044, 0x0045, 0x0046, 0x0047,
+         0x0048, 0x0049, 0x004A, 0x004B, 0x004C, 0x004D, 0x004E, 0x004F,
+         0x0050, 0x0051, 0x0052, 0x0053, 0x0054, 0x0055, 0x0056, 0x0057,
+         0x0058, 0x0059, 0x005A, 0x00B0, 0x00E7, 0x00A7, 0x005E, 0x005F,
+         0x0060, 0x0061, 0x0062, 0x0063, 0x0064, 0x0065, 0x0066, 0x0067,
+         0x0068, 0x0069, 0x006A, 0x006B, 0x006C, 0x006D, 0x006E, 0x006F,
+         0x0070, 0x0071, 0x0072, 0x0073, 0x0074, 0x0075, 0x0076, 0x0077,
+         0x0078, 0x0079, 0x007A, 0x00E9, 0x00F9, 0x00E8, 0x00A8, 0x0020
+     };
+     /**
+      * FRENCH_CA.
+      */
+     public static final char [] FR_CA = {
+         0x0000, 0x0001, 0x0002, 0x0003, 0x0004, 0x0005, 0x0006, 0x0007,
+         0x0008, 0x0009, 0x000A, 0x000B, 0x000C, 0x000D, 0x000E, 0x000F,
+         0x0010, 0x0011, 0x0012, 0x0013, 0x0014, 0x0015, 0x0016, 0x0017,
+         0x0018, 0x0019, 0x001A, 0x001B, 0x001C, 0x001D, 0x001E, 0x001F,
+         0x0020, 0x0021, 0x0022, 0x0023, 0x0024, 0x0025, 0x0026, 0x0027,
+         0x0028, 0x0029, 0x002A, 0x002B, 0x002C, 0x002D, 0x002E, 0x002F,
+         0x0030, 0x0031, 0x0032, 0x0033, 0x0034, 0x0035, 0x0036, 0x0037,
+         0x0038, 0x0039, 0x003A, 0x003B, 0x003C, 0x003D, 0x003E, 0x003F,
+         0x00E0, 0x0041, 0x0042, 0x0043, 0x0044, 0x0045, 0x0046, 0x0047,
+         0x0048, 0x0049, 0x004A, 0x004B, 0x004C, 0x004D, 0x004E, 0x004F,
+         0x0050, 0x0051, 0x0052, 0x0053, 0x0054, 0x0055, 0x0056, 0x0057,
+         0x0058, 0x0059, 0x005A, 0x00E2, 0x00E7, 0x00EA, 0x00EE, 0x005F,
+         0x00F4, 0x0061, 0x0062, 0x0063, 0x0064, 0x0065, 0x0066, 0x0067,
+         0x0068, 0x0069, 0x006A, 0x006B, 0x006C, 0x006D, 0x006E, 0x006F,
+         0x0070, 0x0071, 0x0072, 0x0073, 0x0074, 0x0075, 0x0076, 0x0077,
+         0x0078, 0x0079, 0x007A, 0x00E9, 0x00F9, 0x00E8, 0x00FB, 0x0020
+     };
+     /**
+      * GERMAN.
+      */
+     public static final char [] DE = {
+         0x0000, 0x0001, 0x0002, 0x0003, 0x0004, 0x0005, 0x0006, 0x0007,
+         0x0008, 0x0009, 0x000A, 0x000B, 0x000C, 0x000D, 0x000E, 0x000F,
+         0x0010, 0x0011, 0x0012, 0x0013, 0x0014, 0x0015, 0x0016, 0x0017,
+         0x0018, 0x0019, 0x001A, 0x001B, 0x001C, 0x001D, 0x001E, 0x001F,
+         0x0020, 0x0021, 0x0022, 0x0023, 0x0024, 0x0025, 0x0026, 0x0027,
+         0x0028, 0x0029, 0x002A, 0x002B, 0x002C, 0x002D, 0x002E, 0x002F,
+         0x0030, 0x0031, 0x0032, 0x0033, 0x0034, 0x0035, 0x0036, 0x0037,
+         0x0038, 0x0039, 0x003A, 0x003B, 0x003C, 0x003D, 0x003E, 0x003F,
+         0x00A7, 0x0041, 0x0042, 0x0043, 0x0044, 0x0045, 0x0046, 0x0047,
+         0x0048, 0x0049, 0x004A, 0x004B, 0x004C, 0x004D, 0x004E, 0x004F,
+         0x0050, 0x0051, 0x0052, 0x0053, 0x0054, 0x0055, 0x0056, 0x0057,
+         0x0058, 0x0059, 0x005A, 0x00C4, 0x00D6, 0x00DC, 0x005E, 0x005F,
+         0x0060, 0x0061, 0x0062, 0x0063, 0x0064, 0x0065, 0x0066, 0x0067,
+         0x0068, 0x0069, 0x006A, 0x006B, 0x006C, 0x006D, 0x006E, 0x006F,
+         0x0070, 0x0071, 0x0072, 0x0073, 0x0074, 0x0075, 0x0076, 0x0077,
+         0x0078, 0x0079, 0x007A, 0x00E4, 0x00F6, 0x00FC, 0x00DF, 0x0020
+     };
+     /**
+      * ITALIAN.
+      */
+     public static final char [] IT = {
+         0x0000, 0x0001, 0x0002, 0x0003, 0x0004, 0x0005, 0x0006, 0x0007,
+         0x0008, 0x0009, 0x000A, 0x000B, 0x000C, 0x000D, 0x000E, 0x000F,
+         0x0010, 0x0011, 0x0012, 0x0013, 0x0014, 0x0015, 0x0016, 0x0017,
+         0x0018, 0x0019, 0x001A, 0x001B, 0x001C, 0x001D, 0x001E, 0x001F,
+         0x0020, 0x0021, 0x0022, 0x00A3, 0x0024, 0x0025, 0x0026, 0x0027,
+         0x0028, 0x0029, 0x002A, 0x002B, 0x002C, 0x002D, 0x002E, 0x002F,
+         0x0030, 0x0031, 0x0032, 0x0033, 0x0034, 0x0035, 0x0036, 0x0037,
+         0x0038, 0x0039, 0x003A, 0x003B, 0x003C, 0x003D, 0x003E, 0x003F,
+         0x00A7, 0x0041, 0x0042, 0x0043, 0x0044, 0x0045, 0x0046, 0x0047,
+         0x0048, 0x0049, 0x004A, 0x004B, 0x004C, 0x004D, 0x004E, 0x004F,
+         0x0050, 0x0051, 0x0052, 0x0053, 0x0054, 0x0055, 0x0056, 0x0057,
+         0x0058, 0x0059, 0x005A, 0x00B0, 0x00E7, 0x00E9, 0x005E, 0x005F,
+         0x00F9, 0x0061, 0x0062, 0x0063, 0x0064, 0x0065, 0x0066, 0x0067,
+         0x0068, 0x0069, 0x006A, 0x006B, 0x006C, 0x006D, 0x006E, 0x006F,
+         0x0070, 0x0071, 0x0072, 0x0073, 0x0074, 0x0075, 0x0076, 0x0077,
+         0x0078, 0x0079, 0x007A, 0x00E0, 0x00F2, 0x00E8, 0x00EC, 0x0020
+     };
+     /**
+      * NORWEGIAN.
+      */
+     public static final char [] NO = {
+         0x0000, 0x0001, 0x0002, 0x0003, 0x0004, 0x0005, 0x0006, 0x0007,
+         0x0008, 0x0009, 0x000A, 0x000B, 0x000C, 0x000D, 0x000E, 0x000F,
+         0x0010, 0x0011, 0x0012, 0x0013, 0x0014, 0x0015, 0x0016, 0x0017,
+         0x0018, 0x0019, 0x001A, 0x001B, 0x001C, 0x001D, 0x001E, 0x001F,
+         0x0020, 0x0021, 0x0022, 0x0023, 0x0024, 0x0025, 0x0026, 0x0027,
+         0x0028, 0x0029, 0x002A, 0x002B, 0x002C, 0x002D, 0x002E, 0x002F,
+         0x0030, 0x0031, 0x0032, 0x0033, 0x0034, 0x0035, 0x0036, 0x0037,
+         0x0038, 0x0039, 0x003A, 0x003B, 0x003C, 0x003D, 0x003E, 0x003F,
+         0x00C4, 0x0041, 0x0042, 0x0043, 0x0044, 0x0045, 0x0046, 0x0047,
+         0x0048, 0x0049, 0x004A, 0x004B, 0x004C, 0x004D, 0x004E, 0x004F,
+         0x0050, 0x0051, 0x0052, 0x0053, 0x0054, 0x0055, 0x0056, 0x0057,
+         0x0058, 0x0059, 0x005A, 0x00C6, 0x00D8, 0x00C5, 0x00DC, 0x005F,
+         0x00E4, 0x0061, 0x0062, 0x0063, 0x0064, 0x0065, 0x0066, 0x0067,
+         0x0068, 0x0069, 0x006A, 0x006B, 0x006C, 0x006D, 0x006E, 0x006F,
+         0x0070, 0x0071, 0x0072, 0x0073, 0x0074, 0x0075, 0x0076, 0x0077,
+         0x0078, 0x0079, 0x007A, 0x00E6, 0x00F8, 0x00E5, 0x00FC, 0x0020
+     };
+     /**
+      * SPANISH.
+      */
+     public static final char [] ES = {
+         0x0000, 0x0001, 0x0002, 0x0003, 0x0004, 0x0005, 0x0006, 0x0007,
+         0x0008, 0x0009, 0x000A, 0x000B, 0x000C, 0x000D, 0x000E, 0x000F,
+         0x0010, 0x0011, 0x0012, 0x0013, 0x0014, 0x0015, 0x0016, 0x0017,
+         0x0018, 0x0019, 0x001A, 0x001B, 0x001C, 0x001D, 0x001E, 0x001F,
+         0x0020, 0x0021, 0x0022, 0x00A3, 0x0024, 0x0025, 0x0026, 0x0027,
+         0x0028, 0x0029, 0x002A, 0x002B, 0x002C, 0x002D, 0x002E, 0x002F,
+         0x0030, 0x0031, 0x0032, 0x0033, 0x0034, 0x0035, 0x0036, 0x0037,
+         0x0038, 0x0039, 0x003A, 0x003B, 0x003C, 0x003D, 0x003E, 0x003F,
+         0x00A7, 0x0041, 0x0042, 0x0043, 0x0044, 0x0045, 0x0046, 0x0047,
+         0x0048, 0x0049, 0x004A, 0x004B, 0x004C, 0x004D, 0x004E, 0x004F,
+         0x0050, 0x0051, 0x0052, 0x0053, 0x0054, 0x0055, 0x0056, 0x0057,
+         0x0058, 0x0059, 0x005A, 0x00A1, 0x00D1, 0x00BF, 0x005E, 0x005F,
+         0x0060, 0x0061, 0x0062, 0x0063, 0x0064, 0x0065, 0x0066, 0x0067,
+         0x0068, 0x0069, 0x006A, 0x006B, 0x006C, 0x006D, 0x006E, 0x006F,
+         0x0070, 0x0071, 0x0072, 0x0073, 0x0074, 0x0075, 0x0076, 0x0077,
+         0x0078, 0x0079, 0x007A, 0x00B0, 0x00F1, 0x00E7, 0x007E, 0x0020
+     };
+     /**
+      * SWEDISH.
+      */
+     public static final char [] SV = {
+         0x0000, 0x0001, 0x0002, 0x0003, 0x0004, 0x0005, 0x0006, 0x0007,
+         0x0008, 0x0009, 0x000A, 0x000B, 0x000C, 0x000D, 0x000E, 0x000F,
+         0x0010, 0x0011, 0x0012, 0x0013, 0x0014, 0x0015, 0x0016, 0x0017,
+         0x0018, 0x0019, 0x001A, 0x001B, 0x001C, 0x001D, 0x001E, 0x001F,
+         0x0020, 0x0021, 0x0022, 0x0023, 0x0024, 0x0025, 0x0026, 0x0027,
+         0x0028, 0x0029, 0x002A, 0x002B, 0x002C, 0x002D, 0x002E, 0x002F,
+         0x0030, 0x0031, 0x0032, 0x0033, 0x0034, 0x0035, 0x0036, 0x0037,
+         0x0038, 0x0039, 0x003A, 0x003B, 0x003C, 0x003D, 0x003E, 0x003F,
+         0x00C9, 0x0041, 0x0042, 0x0043, 0x0044, 0x0045, 0x0046, 0x0047,
+         0x0048, 0x0049, 0x004A, 0x004B, 0x004C, 0x004D, 0x004E, 0x004F,
+         0x0050, 0x0051, 0x0052, 0x0053, 0x0054, 0x0055, 0x0056, 0x0057,
+         0x0058, 0x0059, 0x005A, 0x00C4, 0x00D6, 0x00C5, 0x00DC, 0x005F,
+         0x00E9, 0x0061, 0x0062, 0x0063, 0x0064, 0x0065, 0x0066, 0x0067,
+         0x0068, 0x0069, 0x006A, 0x006B, 0x006C, 0x006D, 0x006E, 0x006F,
+         0x0070, 0x0071, 0x0072, 0x0073, 0x0074, 0x0075, 0x0076, 0x0077,
+         0x0078, 0x0079, 0x007A, 0x00E4, 0x00F6, 0x00E5, 0x00FC, 0x0020
+     };
+     /**
+      * SWISS.
+      */
+     public static final char [] SWISS = {
+         0x0000, 0x0001, 0x0002, 0x0003, 0x0004, 0x0005, 0x0006, 0x0007,
+         0x0008, 0x0009, 0x000A, 0x000B, 0x000C, 0x000D, 0x000E, 0x000F,
+         0x0010, 0x0011, 0x0012, 0x0013, 0x0014, 0x0015, 0x0016, 0x0017,
+         0x0018, 0x0019, 0x001A, 0x001B, 0x001C, 0x001D, 0x001E, 0x001F,
+         0x0020, 0x0021, 0x0022, 0x00F9, 0x0024, 0x0025, 0x0026, 0x0027,
+         0x0028, 0x0029, 0x002A, 0x002B, 0x002C, 0x002D, 0x002E, 0x002F,
+         0x0030, 0x0031, 0x0032, 0x0033, 0x0034, 0x0035, 0x0036, 0x0037,
+         0x0038, 0x0039, 0x003A, 0x003B, 0x003C, 0x003D, 0x003E, 0x003F,
+         0x00E0, 0x0041, 0x0042, 0x0043, 0x0044, 0x0045, 0x0046, 0x0047,
+         0x0048, 0x0049, 0x004A, 0x004B, 0x004C, 0x004D, 0x004E, 0x004F,
+         0x0050, 0x0051, 0x0052, 0x0053, 0x0054, 0x0055, 0x0056, 0x0057,
+         0x0058, 0x0059, 0x005A, 0x00E9, 0x00E7, 0x00EA, 0x00EE, 0x00E8,
+         0x00F4, 0x0061, 0x0062, 0x0063, 0x0064, 0x0065, 0x0066, 0x0067,
+         0x0068, 0x0069, 0x006A, 0x006B, 0x006C, 0x006D, 0x006E, 0x006F,
+         0x0070, 0x0071, 0x0072, 0x0073, 0x0074, 0x0075, 0x0076, 0x0077,
+         0x0078, 0x0079, 0x007A, 0x00E4, 0x00F6, 0x00FC, 0x00FB, 0x0020
+     };
+     /**
+      * VT52 drawing characters.
+      */
+     public static final char [] VT52_SPECIAL_GRAPHICS = {
+         0x0000, 0x0001, 0x0002, 0x0003, 0x0004, 0x0005, 0x0006, 0x0007,
+         0x0008, 0x0009, 0x000A, 0x000B, 0x000C, 0x000D, 0x000E, 0x000F,
+         0x0010, 0x0011, 0x0012, 0x0013, 0x0014, 0x0015, 0x0016, 0x0017,
+         0x0018, 0x0019, 0x001A, 0x001B, 0x001C, 0x001D, 0x001E, 0x001F,
+         0x0020, 0x0021, 0x0022, 0x0023, 0x0024, 0x0025, 0x0026, 0x0027,
+         0x0028, 0x0029, 0x002A, 0x002B, 0x002C, 0x002D, 0x002E, 0x002F,
+         0x0030, 0x0031, 0x0032, 0x0033, 0x0034, 0x0035, 0x0036, 0x0037,
+         0x0038, 0x0039, 0x003A, 0x003B, 0x003C, 0x003D, 0x003E, 0x003F,
+         0x0040, 0x0041, 0x0042, 0x0043, 0x0044, 0x0045, 0x0046, 0x0047,
+         0x0048, 0x0049, 0x004A, 0x004B, 0x004C, 0x004D, 0x004E, 0x004F,
+         0x0050, 0x0051, 0x0052, 0x0053, 0x0054, 0x0055, 0x0056, 0x0057,
+         0x0058, 0x0059, 0x005A, 0x005B, 0x005C, 0x005D, 0x0020, 0x0020,
+         0x0020, 0x2588, 0x215F, 0x2592, 0x2592, 0x2592, 0x00B0, 0x00B1,
+         0x2190, 0x2026, 0x00F7, 0x2193, 0x23BA, 0x23BA, 0x23BB, 0x23BB,
+         0x2500, 0x2500, 0x23BC, 0x23BC, 0x2080, 0x2081, 0x2082, 0x2083,
+         0x2084, 0x2085, 0x2086, 0x2087, 0x2088, 0x2089, 0x00B6, 0x0020
+     };
+     // ------------------------------------------------------------------------
+     // Constructors -----------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Private constructor prevents accidental creation of this class.
+      */
+     private DECCharacterSets() {
+     }
+ }
index 0000000000000000000000000000000000000000,06a05a330ddccb50cc3ecf142cea0c4c87c38df9..06a05a330ddccb50cc3ecf142cea0c4c87c38df9
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,251 +1,251 @@@
+ /*
+  * Jexer - Java Text User Interface
+  *
+  * The MIT License (MIT)
+  *
+  * 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"),
+  * to deal in the Software without restriction, including without limitation
+  * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+  * and/or sell copies of the Software, and to permit persons to whom the
+  * Software is furnished to do so, subject to the following conditions:
+  *
+  * The above copyright notice and this permission notice shall be included in
+  * all copies or substantial portions of the Software.
+  *
+  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+  * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+  * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+  * DEALINGS IN THE SOFTWARE.
+  *
+  * @author Kevin Lamonte [kevin.lamonte@gmail.com]
+  * @version 1
+  */
+ package jexer.tterminal;
+ import jexer.bits.Cell;
+ import jexer.bits.CellAttributes;
+ /**
+  * This represents a single line of the display buffer.
+  */
+ public class DisplayLine {
+     // ------------------------------------------------------------------------
+     // Constants --------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Maximum line length.
+      */
+     private static final int MAX_LINE_LENGTH = 256;
+     // ------------------------------------------------------------------------
+     // Variables --------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * The characters/attributes of the line.
+      */
+     private Cell [] chars;
+     /**
+      * Double-width line flag.
+      */
+     private boolean doubleWidth = false;
+     /**
+      * Double height line flag.  Valid values are:
+      *
+      * <p><pre>
+      *   0 = single height
+      *   1 = top half double height
+      *   2 = bottom half double height
+      * </pre>
+      */
+     private int doubleHeight = 0;
+     /**
+      * DECSCNM - reverse video.  We copy the flag to the line so that
+      * reverse-mode scrollback lines still show inverted colors correctly.
+      */
+     private boolean reverseColor = false;
+     // ------------------------------------------------------------------------
+     // Constructors -----------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Public constructor makes a duplicate (deep copy).
+      *
+      * @param line the line to duplicate
+      */
+     public DisplayLine(final DisplayLine line) {
+         chars = new Cell[MAX_LINE_LENGTH];
+         for (int i = 0; i < chars.length; i++) {
+             chars[i] = new Cell(line.chars[i]);
+         }
+         doubleWidth = line.doubleWidth;
+         doubleHeight = line.doubleHeight;
+         reverseColor = line.reverseColor;
+     }
+     /**
+      * Public constructor sets everything to drawing attributes.
+      *
+      * @param attr current drawing attributes
+      */
+     public DisplayLine(final CellAttributes attr) {
+         chars = new Cell[MAX_LINE_LENGTH];
+         for (int i = 0; i < chars.length; i++) {
+             chars[i] = new Cell(attr);
+         }
+     }
+     // ------------------------------------------------------------------------
+     // DisplayLine ------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Get the Cell at a specific column.
+      *
+      * @param idx the character index
+      * @return the Cell
+      */
+     public Cell charAt(final int idx) {
+         return chars[idx];
+     }
+     /**
+      * Get the length of this line.
+      *
+      * @return line length
+      */
+     public int length() {
+         return chars.length;
+     }
+     /**
+      * Get double width flag.
+      *
+      * @return double width
+      */
+     public boolean isDoubleWidth() {
+         return doubleWidth;
+     }
+     /**
+      * Set double width flag.
+      *
+      * @param doubleWidth new value for double width flag
+      */
+     public void setDoubleWidth(final boolean doubleWidth) {
+         this.doubleWidth = doubleWidth;
+     }
+     /**
+      * Get double height flag.
+      *
+      * @return double height
+      */
+     public int getDoubleHeight() {
+         return doubleHeight;
+     }
+     /**
+      * Set double height flag.
+      *
+      * @param doubleHeight new value for double height flag
+      */
+     public void setDoubleHeight(final int doubleHeight) {
+         this.doubleHeight = doubleHeight;
+     }
+     /**
+      * Get reverse video flag.
+      *
+      * @return reverse video
+      */
+     public boolean isReverseColor() {
+         return reverseColor;
+     }
+     /**
+      * Set double-height flag.
+      *
+      * @param reverseColor new value for reverse video flag
+      */
+     public void setReverseColor(final boolean reverseColor) {
+         this.reverseColor = reverseColor;
+     }
+     /**
+      * Insert a character at the specified position.
+      *
+      * @param idx the character index
+      * @param newCell the new Cell
+      */
+     public void insert(final int idx, final Cell newCell) {
+         System.arraycopy(chars, idx, chars, idx + 1, chars.length - idx - 1);
+         chars[idx] = new Cell(newCell);
+     }
+     /**
+      * Replace character at the specified position.
+      *
+      * @param idx the character index
+      * @param newCell the new Cell
+      */
+     public void replace(final int idx, final Cell newCell) {
+         chars[idx].setTo(newCell);
+     }
+     /**
+      * Set the Cell at the specified position to the blank (reset).
+      *
+      * @param idx the character index
+      */
+     public void setBlank(final int idx) {
+         chars[idx].reset();
+     }
+     /**
+      * Set the character (just the char, not the attributes) at the specified
+      * position to ch.
+      *
+      * @param idx the character index
+      * @param ch the new char
+      */
+     public void setChar(final int idx, final int ch) {
+         chars[idx].setChar(ch);
+     }
+     /**
+      * Set the attributes (just the attributes, not the char) at the
+      * specified position to attr.
+      *
+      * @param idx the character index
+      * @param attr the new attributes
+      */
+     public void setAttr(final int idx, final CellAttributes attr) {
+         chars[idx].setAttr(attr);
+     }
+     /**
+      * Delete character at the specified position, filling in the new
+      * character on the right with newCell.
+      *
+      * @param idx the character index
+      * @param newCell the new Cell
+      */
+     public void delete(final int idx, final Cell newCell) {
+         System.arraycopy(chars, idx + 1, chars, idx, chars.length - idx - 1);
+         chars[chars.length - 1] = new Cell(newCell);
+     }
+ }
index 0000000000000000000000000000000000000000,d0c9e2d73f769658a0c7dcdd89ac2e51be3d7f85..d0c9e2d73f769658a0c7dcdd89ac2e51be3d7f85
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,56 +1,56 @@@
+ /*
+  * Jexer - Java Text User Interface
+  *
+  * The MIT License (MIT)
+  *
+  * 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"),
+  * to deal in the Software without restriction, including without limitation
+  * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+  * and/or sell copies of the Software, and to permit persons to whom the
+  * Software is furnished to do so, subject to the following conditions:
+  *
+  * The above copyright notice and this permission notice shall be included in
+  * all copies or substantial portions of the Software.
+  *
+  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+  * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+  * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+  * DEALINGS IN THE SOFTWARE.
+  *
+  * @author Kevin Lamonte [kevin.lamonte@gmail.com]
+  * @version 1
+  */
+ package jexer.tterminal;
+ /**
+  * DisplayListener is used to callback into external UI when data has come in
+  * from the remote side.
+  */
+ public interface DisplayListener {
+     /**
+      * Function to call when the display needs to be updated.
+      */
+     public void displayChanged();
+     /**
+      * Function to call to obtain the display width.
+      *
+      * @return the number of columns in the display
+      */
+     public int getDisplayWidth();
+     /**
+      * Function to call to obtain the display height.
+      *
+      * @return the number of rows in the display
+      */
+     public int getDisplayHeight();
+ }
index 0000000000000000000000000000000000000000,1d3481169cc5300c5134c652e7745a962f42a2ec..1d3481169cc5300c5134c652e7745a962f42a2ec
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,7261 +1,7261 @@@
+ /*
+  * Jexer - Java Text User Interface
+  *
+  * The MIT License (MIT)
+  *
+  * 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"),
+  * to deal in the Software without restriction, including without limitation
+  * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+  * and/or sell copies of the Software, and to permit persons to whom the
+  * Software is furnished to do so, subject to the following conditions:
+  *
+  * The above copyright notice and this permission notice shall be included in
+  * all copies or substantial portions of the Software.
+  *
+  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+  * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+  * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+  * DEALINGS IN THE SOFTWARE.
+  *
+  * @author Kevin Lamonte [kevin.lamonte@gmail.com]
+  * @version 1
+  */
+ package jexer.tterminal;
+ import java.awt.Graphics2D;
+ import java.awt.image.BufferedImage;
+ import java.io.BufferedOutputStream;
+ import java.io.CharArrayWriter;
+ import java.io.InputStream;
+ import java.io.InputStreamReader;
+ import java.io.IOException;
+ import java.io.OutputStream;
+ import java.io.OutputStreamWriter;
+ import java.io.PrintWriter;
+ import java.io.Reader;
+ import java.io.UnsupportedEncodingException;
+ import java.io.Writer;
+ import java.util.ArrayList;
+ import java.util.Collections;
+ import java.util.HashMap;
+ import java.util.List;
+ import jexer.TKeypress;
+ import jexer.backend.GlyphMaker;
+ import jexer.bits.Color;
+ import jexer.bits.Cell;
+ import jexer.bits.CellAttributes;
+ import jexer.bits.StringUtils;
+ import jexer.event.TInputEvent;
+ import jexer.event.TKeypressEvent;
+ import jexer.event.TMouseEvent;
+ import jexer.io.ReadTimeoutException;
+ import jexer.io.TimeoutInputStream;
+ import static jexer.TKeypress.*;
+ /**
+  * This implements a complex ECMA-48/ISO 6429/ANSI X3.64 type console,
+  * including a scrollback buffer.
+  *
+  * <p>
+  * It currently implements VT100, VT102, VT220, and XTERM with the following
+  * caveats:
+  *
+  * <p>
+  * - The vttest scenario for VT220 8-bit controls (11.1.2.3) reports a
+  *   failure with XTERM.  This is due to vttest failing to decode the UTF-8
+  *   stream.
+  *
+  * <p>
+  * - Smooth scrolling, printing, keyboard locking, keyboard leds, and tests
+  *   from VT100 are not supported.
+  *
+  * <p>
+  * - User-defined keys (DECUDK), downloadable fonts (DECDLD), and VT100/ANSI
+  *   compatibility mode (DECSCL) from VT220 are not supported.  (Also,
+  *   because DECSCL is not supported, it will fail the last part of the
+  *   vttest "Test of VT52 mode" if DeviceType is set to VT220.)
+  *
+  * <p>
+  * - Numeric/application keys from the number pad are not supported because
+  *   they are not exposed from the TKeypress API.
+  *
+  * <p>
+  * - VT52 HOLD SCREEN mode is not supported.
+  *
+  * <p>
+  * - In VT52 graphics mode, the 3/, 5/, and 7/ characters (fraction
+  *   numerators) are not rendered correctly.
+  *
+  * <p>
+  * - All data meant for the 'printer' (CSI Pc ? i) is discarded.
+  */
+ public class ECMA48 implements Runnable {
+     // ------------------------------------------------------------------------
+     // Constants --------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * The emulator can emulate several kinds of terminals.
+      */
+     public enum DeviceType {
+         /**
+          * DEC VT100 but also including the three VT102 functions.
+          */
+         VT100,
+         /**
+          * DEC VT102.
+          */
+         VT102,
+         /**
+          * DEC VT220.
+          */
+         VT220,
+         /**
+          * A subset of xterm.
+          */
+         XTERM
+     }
+     /**
+      * Parser character scan states.
+      */
+     private enum ScanState {
+         GROUND,
+         ESCAPE,
+         ESCAPE_INTERMEDIATE,
+         CSI_ENTRY,
+         CSI_PARAM,
+         CSI_INTERMEDIATE,
+         CSI_IGNORE,
+         DCS_ENTRY,
+         DCS_INTERMEDIATE,
+         DCS_PARAM,
+         DCS_PASSTHROUGH,
+         DCS_IGNORE,
+         DCS_SIXEL,
+         SOSPMAPC_STRING,
+         OSC_STRING,
+         VT52_DIRECT_CURSOR_ADDRESS
+     }
+     /**
+      * The selected number pad mode (DECKPAM, DECKPNM).  We record this, but
+      * can't really use it in keypress() because we do not see number pad
+      * events from TKeypress.
+      */
+     private enum KeypadMode {
+         Application,
+         Numeric
+     }
+     /**
+      * Arrow keys can emit three different sequences (DECCKM or VT52
+      * submode).
+      */
+     private enum ArrowKeyMode {
+         VT52,
+         ANSI,
+         VT100
+     }
+     /**
+      * Available character sets for GL, GR, G0, G1, G2, G3.
+      */
+     private enum CharacterSet {
+         US,
+         UK,
+         DRAWING,
+         ROM,
+         ROM_SPECIAL,
+         VT52_GRAPHICS,
+         DEC_SUPPLEMENTAL,
+         NRC_DUTCH,
+         NRC_FINNISH,
+         NRC_FRENCH,
+         NRC_FRENCH_CA,
+         NRC_GERMAN,
+         NRC_ITALIAN,
+         NRC_NORWEGIAN,
+         NRC_SPANISH,
+         NRC_SWEDISH,
+         NRC_SWISS
+     }
+     /**
+      * Single-shift states used by the C1 control characters SS2 (0x8E) and
+      * SS3 (0x8F).
+      */
+     private enum Singleshift {
+         NONE,
+         SS2,
+         SS3
+     }
+     /**
+      * VT220+ lockshift states.
+      */
+     private enum LockshiftMode {
+         NONE,
+         G1_GR,
+         G2_GR,
+         G2_GL,
+         G3_GR,
+         G3_GL
+     }
+     /**
+      * XTERM mouse reporting protocols.
+      */
+     public enum MouseProtocol {
+         OFF,
+         X10,
+         NORMAL,
+         BUTTONEVENT,
+         ANYEVENT
+     }
+     /**
+      * XTERM mouse reporting encodings.
+      */
+     private enum MouseEncoding {
+         X10,
+         UTF8,
+         SGR
+     }
+     // ------------------------------------------------------------------------
+     // Variables --------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * The enclosing listening object.
+      */
+     private DisplayListener displayListener;
+     /**
+      * When true, the reader thread is expected to exit.
+      */
+     private volatile boolean stopReaderThread = false;
+     /**
+      * The reader thread.
+      */
+     private Thread readerThread = null;
+     /**
+      * The type of emulator to be.
+      */
+     private DeviceType type = DeviceType.VT102;
+     /**
+      * The scrollback buffer characters + attributes.
+      */
+     private volatile ArrayList<DisplayLine> scrollback;
+     /**
+      * The raw display buffer characters + attributes.
+      */
+     private volatile ArrayList<DisplayLine> display;
+     /**
+      * The maximum number of lines in the scrollback buffer.
+      */
+     private int maxScrollback = 10000;
+     /**
+      * The terminal's input.  For type == XTERM, this is an InputStreamReader
+      * with UTF-8 encoding.
+      */
+     private Reader input;
+     /**
+      * The terminal's raw InputStream.  This is used for type != XTERM.
+      */
+     private volatile TimeoutInputStream inputStream;
+     /**
+      * The terminal's output.  For type == XTERM, this wraps an
+      * OutputStreamWriter with UTF-8 encoding.
+      */
+     private Writer output;
+     /**
+      * The terminal's raw OutputStream.  This is used for type != XTERM.
+      */
+     private OutputStream outputStream;
+     /**
+      * Current scanning state.
+      */
+     private ScanState scanState;
+     /**
+      * Which mouse protocol is active.
+      */
+     private MouseProtocol mouseProtocol = MouseProtocol.OFF;
+     /**
+      * Which mouse encoding is active.
+      */
+     private MouseEncoding mouseEncoding = MouseEncoding.X10;
+     /**
+      * A terminal may request that the mouse pointer be hidden using a
+      * Privacy Message containing either "hideMousePointer" or
+      * "showMousePointer".  This is currently only used within Jexer by
+      * TTerminalWindow so that only the bottom-most instance of nested
+      * Jexer's draws the mouse within its application window.
+      */
+     private boolean hideMousePointer = false;
+     /**
+      * Physical display width.  We start at 80x24, but the user can resize us
+      * bigger/smaller.
+      */
+     private int width;
+     /**
+      * Physical display height.  We start at 80x24, but the user can resize
+      * us bigger/smaller.
+      */
+     private int height;
+     /**
+      * Top margin of the scrolling region.
+      */
+     private int scrollRegionTop;
+     /**
+      * Bottom margin of the scrolling region.
+      */
+     private int scrollRegionBottom;
+     /**
+      * Right margin column number.  This can be selected by the remote side
+      * to be 80/132 (rightMargin values 79/131), or it can be (width - 1).
+      */
+     private int rightMargin;
+     /**
+      * Last character printed.
+      */
+     private int repCh;
+     /**
+      * VT100-style line wrapping: a character is placed in column 80 (or
+      * 132), but the line does NOT wrap until another character is written to
+      * column 1 of the next line, after which the cursor moves to column 2.
+      */
+     private boolean wrapLineFlag;
+     /**
+      * VT220 single shift flag.
+      */
+     private Singleshift singleshift = Singleshift.NONE;
+     /**
+      * true = insert characters, false = overwrite.
+      */
+     private boolean insertMode = false;
+     /**
+      * VT52 mode as selected by DECANM.  True means VT52, false means
+      * ANSI. Default is ANSI.
+      */
+     private boolean vt52Mode = false;
+     /**
+      * Visible cursor (DECTCEM).
+      */
+     private boolean cursorVisible = true;
+     /**
+      * Screen title as set by the xterm OSC sequence.  Lots of applications
+      * send a screenTitle regardless of whether it is an xterm client or not.
+      */
+     private String screenTitle = "";
+     /**
+      * Parameter characters being collected.
+      */
+     private List<Integer> csiParams;
+     /**
+      * Non-csi collect buffer.
+      */
+     private StringBuilder collectBuffer;
+     /**
+      * When true, use the G1 character set.
+      */
+     private boolean shiftOut = false;
+     /**
+      * Horizontal tab stop locations.
+      */
+     private List<Integer> tabStops;
+     /**
+      * S8C1T.  True means 8bit controls, false means 7bit controls.
+      */
+     private boolean s8c1t = false;
+     /**
+      * Printer mode.  True means send all output to printer, which discards
+      * it.
+      */
+     private boolean printerControllerMode = false;
+     /**
+      * LMN line mode.  If true, linefeed() puts the cursor on the first
+      * column of the next line.  If false, linefeed() puts the cursor one
+      * line down on the current line.  The default is false.
+      */
+     private boolean newLineMode = false;
+     /**
+      * Whether arrow keys send ANSI, VT100, or VT52 sequences.
+      */
+     private ArrowKeyMode arrowKeyMode;
+     /**
+      * Whether number pad keys send VT100 or VT52, application or numeric
+      * sequences.
+      */
+     @SuppressWarnings("unused")
+     private KeypadMode keypadMode;
+     /**
+      * When true, the terminal is in 132-column mode (DECCOLM).
+      */
+     private boolean columns132 = false;
+     /**
+      * true = reverse video.  Set by DECSCNM.
+      */
+     private boolean reverseVideo = false;
+     /**
+      * false = echo characters locally.
+      */
+     private boolean fullDuplex = true;
+     /**
+      * The current terminal state.
+      */
+     private SaveableState currentState;
+     /**
+      * The last saved terminal state.
+      */
+     private SaveableState savedState;
+     /**
+      * The 88- or 256-color support RGB colors.
+      */
+     private List<Integer> colors88;
+     /**
+      * Sixel collection buffer.
+      */
+     private StringBuilder sixelParseBuffer;
+     /**
+      * Sixel shared palette.
+      */
+     private HashMap<Integer, java.awt.Color> sixelPalette;
+     /**
+      * The width of a character cell in pixels.
+      */
+     private int textWidth = 16;
+     /**
+      * The height of a character cell in pixels.
+      */
+     private int textHeight = 20;
+     /**
+      * The last used height of a character cell in pixels, only used for
+      * full-width chars.
+      */
+     private int lastTextHeight = -1;
+     /**
+      * The glyph drawer for full-width chars.
+      */
+     private GlyphMaker glyphMaker = null;
+     /**
+      * Input queue for keystrokes and mouse events to send to the remote
+      * side.
+      */
+     private ArrayList<TInputEvent> userQueue = new ArrayList<TInputEvent>();
+     /**
+      * DECSC/DECRC save/restore a subset of the total state.  This class
+      * encapsulates those specific flags/modes.
+      */
+     private class SaveableState {
+         /**
+          * When true, cursor positions are relative to the scrolling region.
+          */
+         public boolean originMode = false;
+         /**
+          * The current editing X position.
+          */
+         public int cursorX = 0;
+         /**
+          * The current editing Y position.
+          */
+         public int cursorY = 0;
+         /**
+          * Which character set is currently selected in G0.
+          */
+         public CharacterSet g0Charset = CharacterSet.US;
+         /**
+          * Which character set is currently selected in G1.
+          */
+         public CharacterSet g1Charset = CharacterSet.DRAWING;
+         /**
+          * Which character set is currently selected in G2.
+          */
+         public CharacterSet g2Charset = CharacterSet.US;
+         /**
+          * Which character set is currently selected in G3.
+          */
+         public CharacterSet g3Charset = CharacterSet.US;
+         /**
+          * Which character set is currently selected in GR.
+          */
+         public CharacterSet grCharset = CharacterSet.DRAWING;
+         /**
+          * The current drawing attributes.
+          */
+         public CellAttributes attr;
+         /**
+          * GL lockshift mode.
+          */
+         public LockshiftMode glLockshift = LockshiftMode.NONE;
+         /**
+          * GR lockshift mode.
+          */
+         public LockshiftMode grLockshift = LockshiftMode.NONE;
+         /**
+          * Line wrap.
+          */
+         public boolean lineWrap = true;
+         /**
+          * Reset to defaults.
+          */
+         public void reset() {
+             originMode          = false;
+             cursorX             = 0;
+             cursorY             = 0;
+             g0Charset           = CharacterSet.US;
+             g1Charset           = CharacterSet.DRAWING;
+             g2Charset           = CharacterSet.US;
+             g3Charset           = CharacterSet.US;
+             grCharset           = CharacterSet.DRAWING;
+             attr                = new CellAttributes();
+             glLockshift         = LockshiftMode.NONE;
+             grLockshift         = LockshiftMode.NONE;
+             lineWrap            = true;
+         }
+         /**
+          * Copy attributes from another instance.
+          *
+          * @param that the other instance to match
+          */
+         public void setTo(final SaveableState that) {
+             this.originMode     = that.originMode;
+             this.cursorX        = that.cursorX;
+             this.cursorY        = that.cursorY;
+             this.g0Charset      = that.g0Charset;
+             this.g1Charset      = that.g1Charset;
+             this.g2Charset      = that.g2Charset;
+             this.g3Charset      = that.g3Charset;
+             this.grCharset      = that.grCharset;
+             this.attr           = new CellAttributes();
+             this.attr.setTo(that.attr);
+             this.glLockshift    = that.glLockshift;
+             this.grLockshift    = that.grLockshift;
+             this.lineWrap       = that.lineWrap;
+         }
+         /**
+          * Public constructor.
+          */
+         public SaveableState() {
+             reset();
+         }
+     }
+     // ------------------------------------------------------------------------
+     // Constructors -----------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Public constructor.
+      *
+      * @param type one of the DeviceType constants to select VT100, VT102,
+      * VT220, or XTERM
+      * @param inputStream an InputStream connected to the remote side.  For
+      * type == XTERM, inputStream is converted to a Reader with UTF-8
+      * encoding.
+      * @param outputStream an OutputStream connected to the remote user.  For
+      * type == XTERM, outputStream is converted to a Writer with UTF-8
+      * encoding.
+      * @param displayListener a callback to the outer display, or null for
+      * default VT100 behavior
+      * @throws UnsupportedEncodingException if an exception is thrown when
+      * creating the InputStreamReader
+      */
+     public ECMA48(final DeviceType type, final InputStream inputStream,
+         final OutputStream outputStream, final DisplayListener displayListener)
+         throws UnsupportedEncodingException {
+         assert (inputStream != null);
+         assert (outputStream != null);
+         csiParams         = new ArrayList<Integer>();
+         tabStops          = new ArrayList<Integer>();
+         scrollback        = new ArrayList<DisplayLine>();
+         display           = new ArrayList<DisplayLine>();
+         this.type         = type;
+         if (inputStream instanceof TimeoutInputStream) {
+             this.inputStream  = (TimeoutInputStream)inputStream;
+         } else {
+             this.inputStream  = new TimeoutInputStream(inputStream, 2000);
+         }
+         if (type == DeviceType.XTERM) {
+             this.input    = new InputStreamReader(this.inputStream, "UTF-8");
+             this.output   = new OutputStreamWriter(new
+                 BufferedOutputStream(outputStream), "UTF-8");
+             this.outputStream = null;
+         } else {
+             this.output       = null;
+             this.outputStream = new BufferedOutputStream(outputStream);
+         }
+         this.displayListener  = displayListener;
+         reset();
+         for (int i = 0; i < height; i++) {
+             display.add(new DisplayLine(currentState.attr));
+         }
+         // Spin up the input reader
+         readerThread = new Thread(this);
+         readerThread.start();
+     }
+     // ------------------------------------------------------------------------
+     // Runnable ---------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Read function runs on a separate thread.
+      */
+     public final void run() {
+         boolean utf8 = false;
+         boolean done = false;
+         if (type == DeviceType.XTERM) {
+             utf8 = true;
+         }
+         // available() will often return > 1, so we need to read in chunks to
+         // stay caught up.
+         char [] readBufferUTF8 = null;
+         byte [] readBuffer = null;
+         if (utf8) {
+             readBufferUTF8 = new char[2048];
+         } else {
+             readBuffer = new byte[2048];
+         }
+         while (!done && !stopReaderThread) {
+             synchronized (userQueue) {
+                 while (userQueue.size() > 0) {
+                     handleUserEvent(userQueue.remove(0));
+                 }
+             }
+             try {
+                 int n = inputStream.available();
+                 // System.err.printf("available() %d\n", n); System.err.flush();
+                 if (utf8) {
+                     if (readBufferUTF8.length < n) {
+                         // The buffer wasn't big enough, make it huger
+                         int newSizeHalf = Math.max(readBufferUTF8.length,
+                             n);
+                         readBufferUTF8 = new char[newSizeHalf * 2];
+                     }
+                 } else {
+                     if (readBuffer.length < n) {
+                         // The buffer wasn't big enough, make it huger
+                         int newSizeHalf = Math.max(readBuffer.length, n);
+                         readBuffer = new byte[newSizeHalf * 2];
+                     }
+                 }
+                 if (n == 0) {
+                     try {
+                         Thread.sleep(10);
+                     } catch (InterruptedException e) {
+                         // SQUASH
+                     }
+                     continue;
+                 }
+                 int rc = -1;
+                 try {
+                     if (utf8) {
+                         rc = input.read(readBufferUTF8, 0,
+                             readBufferUTF8.length);
+                     } else {
+                         rc = inputStream.read(readBuffer, 0,
+                             readBuffer.length);
+                     }
+                 } catch (ReadTimeoutException e) {
+                     rc = 0;
+                 }
+                 // System.err.printf("read() %d\n", rc); System.err.flush();
+                 if (rc == -1) {
+                     // This is EOF
+                     done = true;
+                 } else {
+                     // Don't step on UI events
+                     synchronized (this) {
+                         if (utf8) {
+                             for (int i = 0; i < rc;) {
+                                 int ch = Character.codePointAt(readBufferUTF8,
+                                     i);
+                                 i += Character.charCount(ch);
+                                 consume(ch);
+                             }
+                         } else {
+                             for (int i = 0; i < rc; i++) {
+                                 consume(readBuffer[i]);
+                             }
+                         }
+                     }
+                     // Permit my enclosing UI to know that I updated.
+                     if (displayListener != null) {
+                         displayListener.displayChanged();
+                     }
+                 }
+                 // System.err.println("end while loop"); System.err.flush();
+             } catch (IOException e) {
+                 done = true;
+                 // This is an unusual case.  We want to see the stack trace,
+                 // but it is related to the spawned process rather than the
+                 // actual UI.  We will generate the stack trace, and consume
+                 // it as though it was emitted by the shell.
+                 CharArrayWriter writer= new CharArrayWriter();
+                 // Send a ST and RIS to clear the emulator state.
+                 try {
+                     writer.write("\033\\\033c");
+                     writer.write("\n-----------------------------------\n");
+                     e.printStackTrace(new PrintWriter(writer));
+                     writer.write("\n-----------------------------------\n");
+                 } catch (IOException e2) {
+                     // SQUASH
+                 }
+                 char [] stackTrace = writer.toCharArray();
+                 for (int i = 0; i < stackTrace.length; i++) {
+                     if (stackTrace[i] == '\n') {
+                         consume('\r');
+                     }
+                     consume(stackTrace[i]);
+                 }
+             }
+         } // while ((done == false) && (stopReaderThread == false))
+         // Let the rest of the world know that I am done.
+         stopReaderThread = true;
+         try {
+             inputStream.cancelRead();
+             inputStream.close();
+             inputStream = null;
+         } catch (IOException e) {
+             // SQUASH
+         }
+         try {
+             input.close();
+             input = null;
+         } catch (IOException e) {
+             // SQUASH
+         }
+         // Permit my enclosing UI to know that I updated.
+         if (displayListener != null) {
+             displayListener.displayChanged();
+         }
+         // System.err.println("*** run() exiting..."); System.err.flush();
+     }
+     // ------------------------------------------------------------------------
+     // ECMA48 -----------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Process keyboard and mouse events from the user.
+      *
+      * @param event the input event to consume
+      */
+     private void handleUserEvent(final TInputEvent event) {
+         if (event instanceof TKeypressEvent) {
+             keypress(((TKeypressEvent) event).getKey());
+         }
+         if (event instanceof TMouseEvent) {
+             mouse((TMouseEvent) event);
+         }
+     }
+     /**
+      * Add a keyboard and mouse event from the user to the queue.
+      *
+      * @param event the input event to consume
+      */
+     public void addUserEvent(final TInputEvent event) {
+         synchronized (userQueue) {
+             userQueue.add(event);
+         }
+     }
+     /**
+      * Return the proper primary Device Attributes string.
+      *
+      * @return string to send to remote side that is appropriate for the
+      * this.type
+      */
+     private String deviceTypeResponse() {
+         switch (type) {
+         case VT100:
+             // "I am a VT100 with advanced video option" (often VT102)
+             return "\033[?1;2c";
+         case VT102:
+             // "I am a VT102"
+             return "\033[?6c";
+         case VT220:
+         case XTERM:
+             // "I am a VT220" - 7 bit version
+             if (!s8c1t) {
+                 return "\033[?62;1;6;9;4;22c";
+                 // return "\033[?62;1;6;9;4;22;444c";
+             }
+             // "I am a VT220" - 8 bit version
+             return "\u009b?62;1;6;9;4;22c";
+             // return "\u009b?62;1;6;9;4;22;444c";
+         default:
+             throw new IllegalArgumentException("Invalid device type: " + type);
+         }
+     }
+     /**
+      * Return the proper TERM environment variable for this device type.
+      *
+      * @param deviceType DeviceType.VT100, DeviceType, XTERM, etc.
+      * @return "vt100", "xterm", etc.
+      */
+     public static String deviceTypeTerm(final DeviceType deviceType) {
+         switch (deviceType) {
+         case VT100:
+             return "vt100";
+         case VT102:
+             return "vt102";
+         case VT220:
+             return "vt220";
+         case XTERM:
+             return "xterm";
+         default:
+             throw new IllegalArgumentException("Invalid device type: "
+                 + deviceType);
+         }
+     }
+     /**
+      * Return the proper LANG for this device type.  Only XTERM devices know
+      * about UTF-8, the others are defined by their standard to be either
+      * 7-bit or 8-bit characters only.
+      *
+      * @param deviceType DeviceType.VT100, DeviceType, XTERM, etc.
+      * @param baseLang a base language without UTF-8 flag such as "C" or
+      * "en_US"
+      * @return "en_US", "en_US.UTF-8", etc.
+      */
+     public static String deviceTypeLang(final DeviceType deviceType,
+         final String baseLang) {
+         switch (deviceType) {
+         case VT100:
+         case VT102:
+         case VT220:
+             return baseLang;
+         case XTERM:
+             return baseLang + ".UTF-8";
+         default:
+             throw new IllegalArgumentException("Invalid device type: "
+                 + deviceType);
+         }
+     }
+     /**
+      * Write a string directly to the remote side.
+      *
+      * @param str string to send
+      */
+     public void writeRemote(final String str) {
+         if (stopReaderThread) {
+             // Reader hit EOF, bail out now.
+             close();
+             return;
+         }
+         // System.err.printf("writeRemote() '%s'\n", str);
+         switch (type) {
+         case VT100:
+         case VT102:
+         case VT220:
+             if (outputStream == null) {
+                 return;
+             }
+             try {
+                 outputStream.flush();
+                 for (int i = 0; i < str.length(); i++) {
+                     outputStream.write(str.charAt(i));
+                 }
+                 outputStream.flush();
+             } catch (IOException e) {
+                 // Assume EOF
+                 close();
+             }
+             break;
+         case XTERM:
+             if (output == null) {
+                 return;
+             }
+             try {
+                 output.flush();
+                 output.write(str);
+                 output.flush();
+             } catch (IOException e) {
+                 // Assume EOF
+                 close();
+             }
+             break;
+         default:
+             throw new IllegalArgumentException("Invalid device type: " + type);
+         }
+     }
+     /**
+      * Close the input and output streams and stop the reader thread.  Note
+      * that it is safe to call this multiple times.
+      */
+     public final void close() {
+         // Tell the reader thread to stop looking at input.  It will close
+         // the input streams.
+         if (stopReaderThread == false) {
+             stopReaderThread = true;
+             try {
+                 readerThread.join(1000);
+             } catch (InterruptedException e) {
+                 // SQUASH
+             }
+         }
+         // Now close the output stream.
+         switch (type) {
+         case VT100:
+         case VT102:
+         case VT220:
+             if (outputStream != null) {
+                 try {
+                     outputStream.close();
+                 } catch (IOException e) {
+                     // SQUASH
+                 }
+                 outputStream = null;
+             }
+             break;
+         case XTERM:
+             if (outputStream != null) {
+                 try {
+                     outputStream.close();
+                 } catch (IOException e) {
+                     // SQUASH
+                 }
+                 outputStream = null;
+             }
+             if (output != null) {
+                 try {
+                     output.close();
+                 } catch (IOException e) {
+                     // SQUASH
+                 }
+                 output = null;
+             }
+             break;
+         default:
+             throw new IllegalArgumentException("Invalid device type: " +
+                 type);
+         }
+     }
+     /**
+      * See if the reader thread is still running.
+      *
+      * @return if true, we are still connected to / reading from the remote
+      * side
+      */
+     public final boolean isReading() {
+         return (!stopReaderThread);
+     }
+     /**
+      * Obtain a new blank display line for an external user
+      * (e.g. TTerminalWindow).
+      *
+      * @return new blank line
+      */
+     public final DisplayLine getBlankDisplayLine() {
+         return new DisplayLine(currentState.attr);
+     }
+     /**
+      * Get the scrollback buffer.
+      *
+      * @return the scrollback buffer
+      */
+     public final List<DisplayLine> getScrollbackBuffer() {
+         return scrollback;
+     }
+     /**
+      * Get the display buffer.
+      *
+      * @return the display buffer
+      */
+     public final List<DisplayLine> getDisplayBuffer() {
+         return display;
+     }
+     /**
+      * Get the visible display + scrollback buffer, offset by a specified
+      * number of rows from the bottom.
+      *
+      * @param visibleHeight the total height of the display to show
+      * @param scrollBottom the number of rows from the bottom to scroll back
+      * @return a copy of the display + scrollback buffers
+      */
+     public final List<DisplayLine> getVisibleDisplay(final int visibleHeight,
+         final int scrollBottom) {
+         assert (visibleHeight >= 0);
+         assert (scrollBottom >= 0);
+         int visibleBottom = scrollback.size() + display.size() - scrollBottom;
+         List<DisplayLine> preceedingBlankLines = new ArrayList<DisplayLine>();
+         int visibleTop = visibleBottom - visibleHeight;
+         if (visibleTop < 0) {
+             for (int i = visibleTop; i < 0; i++) {
+                 preceedingBlankLines.add(getBlankDisplayLine());
+             }
+             visibleTop = 0;
+         }
+         assert (visibleTop >= 0);
+         List<DisplayLine> displayLines = new ArrayList<DisplayLine>();
+         displayLines.addAll(scrollback);
+         displayLines.addAll(display);
+         List<DisplayLine> visibleLines = new ArrayList<DisplayLine>();
+         visibleLines.addAll(preceedingBlankLines);
+         visibleLines.addAll(displayLines.subList(visibleTop, visibleBottom));
+         // Fill in the blank lines on bottom
+         int bottomBlankLines = visibleHeight - visibleLines.size();
+         assert (bottomBlankLines >= 0);
+         for (int i = 0; i < bottomBlankLines; i++) {
+             visibleLines.add(getBlankDisplayLine());
+         }
+         return copyBuffer(visibleLines);
+     }
+     /**
+      * Copy a display buffer.
+      *
+      * @param buffer the buffer to copy
+      * @return a deep copy of the buffer's data
+      */
+     private List<DisplayLine> copyBuffer(final List<DisplayLine> buffer) {
+         ArrayList<DisplayLine> result = new ArrayList<DisplayLine>(buffer.size());
+         for (DisplayLine line: buffer) {
+             result.add(new DisplayLine(line));
+         }
+         return result;
+     }
+     /**
+      * Get the display width.
+      *
+      * @return the width (usually 80 or 132)
+      */
+     public final int getWidth() {
+         return width;
+     }
+     /**
+      * Set the display width.
+      *
+      * @param width the new width
+      */
+     public final synchronized void setWidth(final int width) {
+         this.width = width;
+         rightMargin = width - 1;
+         if (currentState.cursorX >= width) {
+             currentState.cursorX = width - 1;
+         }
+         if (savedState.cursorX >= width) {
+             savedState.cursorX = width - 1;
+         }
+     }
+     /**
+      * Get the display height.
+      *
+      * @return the height (usually 24)
+      */
+     public final int getHeight() {
+         return height;
+     }
+     /**
+      * Set the display height.
+      *
+      * @param height the new height
+      */
+     public final synchronized void setHeight(final int height) {
+         int delta = height - this.height;
+         this.height = height;
+         scrollRegionBottom += delta;
+         if (scrollRegionBottom < 0) {
+             scrollRegionBottom = height;
+         }
+         if (scrollRegionTop >= scrollRegionBottom) {
+             scrollRegionTop = 0;
+         }
+         if (currentState.cursorY >= height) {
+             currentState.cursorY = height - 1;
+         }
+         if (savedState.cursorY >= height) {
+             savedState.cursorY = height - 1;
+         }
+         while (display.size() < height) {
+             DisplayLine line = new DisplayLine(currentState.attr);
+             line.setReverseColor(reverseVideo);
+             display.add(line);
+         }
+         while (display.size() > height) {
+             scrollback.add(display.remove(0));
+         }
+     }
+     /**
+      * Get visible cursor flag.
+      *
+      * @return if true, the cursor is visible
+      */
+     public final boolean isCursorVisible() {
+         return cursorVisible;
+     }
+     /**
+      * Get the screen title as set by the xterm OSC sequence.  Lots of
+      * applications send a screenTitle regardless of whether it is an xterm
+      * client or not.
+      *
+      * @return screen title
+      */
+     public final String getScreenTitle() {
+         return screenTitle;
+     }
+     /**
+      * Get 132 columns value.
+      *
+      * @return if true, the terminal is in 132 column mode
+      */
+     public final boolean isColumns132() {
+         return columns132;
+     }
+     /**
+      * Clear the CSI parameters and flags.
+      */
+     private void toGround() {
+         csiParams.clear();
+         collectBuffer = new StringBuilder(8);
+         scanState = ScanState.GROUND;
+     }
+     /**
+      * Reset the tab stops list.
+      */
+     private void resetTabStops() {
+         tabStops.clear();
+         for (int i = 0; (i * 8) <= rightMargin; i++) {
+             tabStops.add(Integer.valueOf(i * 8));
+         }
+     }
+     /**
+      * Reset the 88- or 256-colors.
+      */
+     private void resetColors() {
+         colors88 = new ArrayList<Integer>(256);
+         for (int i = 0; i < 256; i++) {
+             colors88.add(0);
+         }
+         // Set default system colors.
+         colors88.set(0, 0x00000000);
+         colors88.set(1, 0x00a80000);
+         colors88.set(2, 0x0000a800);
+         colors88.set(3, 0x00a85400);
+         colors88.set(4, 0x000000a8);
+         colors88.set(5, 0x00a800a8);
+         colors88.set(6, 0x0000a8a8);
+         colors88.set(7, 0x00a8a8a8);
+         colors88.set(8, 0x00545454);
+         colors88.set(9, 0x00fc5454);
+         colors88.set(10, 0x0054fc54);
+         colors88.set(11, 0x00fcfc54);
+         colors88.set(12, 0x005454fc);
+         colors88.set(13, 0x00fc54fc);
+         colors88.set(14, 0x0054fcfc);
+         colors88.set(15, 0x00fcfcfc);
+     }
+     /**
+      * Get the RGB value of one of the indexed colors.
+      *
+      * @param index the color index
+      * @return the RGB value
+      */
+     private int get88Color(final int index) {
+         // System.err.print("get88Color: " + index);
+         if ((index < 0) || (index > colors88.size())) {
+             // System.err.println(" -- UNKNOWN");
+             return 0;
+         }
+         // System.err.printf(" %08x\n", colors88.get(index));
+         return colors88.get(index);
+     }
+     /**
+      * Set one of the indexed colors to a color specification.
+      *
+      * @param index the color index
+      * @param spec the specification, typically something like "rgb:aa/bb/cc"
+      */
+     private void set88Color(final int index, final String spec) {
+         // System.err.println("set88Color: " + index + " '" + spec + "'");
+         if ((index < 0) || (index > colors88.size())) {
+             return;
+         }
+         if (spec.startsWith("rgb:")) {
+             String [] rgbTokens = spec.substring(4).split("/");
+             if (rgbTokens.length == 3) {
+                 try {
+                     int rgb = (Integer.parseInt(rgbTokens[0], 16) << 16);
+                     rgb |= Integer.parseInt(rgbTokens[1], 16) << 8;
+                     rgb |= Integer.parseInt(rgbTokens[2], 16);
+                     // System.err.printf("  set to %08x\n", rgb);
+                     colors88.set(index, rgb);
+                 } catch (NumberFormatException e) {
+                     // SQUASH
+                 }
+             }
+             return;
+         }
+         if (spec.toLowerCase().equals("black")) {
+             colors88.set(index, 0x00000000);
+         } else if (spec.toLowerCase().equals("red")) {
+             colors88.set(index, 0x00a80000);
+         } else if (spec.toLowerCase().equals("green")) {
+             colors88.set(index, 0x0000a800);
+         } else if (spec.toLowerCase().equals("yellow")) {
+             colors88.set(index, 0x00a85400);
+         } else if (spec.toLowerCase().equals("blue")) {
+             colors88.set(index, 0x000000a8);
+         } else if (spec.toLowerCase().equals("magenta")) {
+             colors88.set(index, 0x00a800a8);
+         } else if (spec.toLowerCase().equals("cyan")) {
+             colors88.set(index, 0x0000a8a8);
+         } else if (spec.toLowerCase().equals("white")) {
+             colors88.set(index, 0x00a8a8a8);
+         }
+     }
+     /**
+      * Reset the emulation state.
+      */
+     private void reset() {
+         currentState            = new SaveableState();
+         savedState              = new SaveableState();
+         scanState               = ScanState.GROUND;
+         width                   = 80;
+         height                  = 24;
+         scrollRegionTop         = 0;
+         scrollRegionBottom      = height - 1;
+         rightMargin             = width - 1;
+         newLineMode             = false;
+         arrowKeyMode            = ArrowKeyMode.ANSI;
+         keypadMode              = KeypadMode.Numeric;
+         wrapLineFlag            = false;
+         if (displayListener != null) {
+             width = displayListener.getDisplayWidth();
+             height = displayListener.getDisplayHeight();
+             rightMargin         = width - 1;
+         }
+         // Flags
+         shiftOut                = false;
+         vt52Mode                = false;
+         insertMode              = false;
+         columns132              = false;
+         newLineMode             = false;
+         reverseVideo            = false;
+         fullDuplex              = true;
+         cursorVisible           = true;
+         // VT220
+         singleshift             = Singleshift.NONE;
+         s8c1t                   = false;
+         printerControllerMode   = false;
+         // XTERM
+         mouseProtocol           = MouseProtocol.OFF;
+         mouseEncoding           = MouseEncoding.X10;
+         // Tab stops
+         resetTabStops();
+         // Reset extra colors
+         resetColors();
+         // Clear CSI stuff
+         toGround();
+     }
+     /**
+      * Append a new line to the bottom of the display, adding lines off the
+      * top to the scrollback buffer.
+      */
+     private void newDisplayLine() {
+         // Scroll the top line off into the scrollback buffer
+         scrollback.add(display.get(0));
+         if (scrollback.size() > maxScrollback) {
+             scrollback.remove(0);
+             scrollback.trimToSize();
+         }
+         display.remove(0);
+         display.trimToSize();
+         DisplayLine line = new DisplayLine(currentState.attr);
+         line.setReverseColor(reverseVideo);
+         display.add(line);
+     }
+     /**
+      * Wraps the current line.
+      */
+     private void wrapCurrentLine() {
+         if (currentState.cursorY == height - 1) {
+             newDisplayLine();
+         }
+         if (currentState.cursorY < height - 1) {
+             currentState.cursorY++;
+         }
+         currentState.cursorX = 0;
+     }
+     /**
+      * Handle a carriage return.
+      */
+     private void carriageReturn() {
+         currentState.cursorX = 0;
+         wrapLineFlag = false;
+     }
+     /**
+      * Reverse the color of the visible display.
+      */
+     private void invertDisplayColors() {
+         for (DisplayLine line: display) {
+             line.setReverseColor(!line.isReverseColor());
+         }
+     }
+     /**
+      * Handle a linefeed.
+      */
+     private void linefeed() {
+         if (currentState.cursorY < scrollRegionBottom) {
+             // Increment screen y
+             currentState.cursorY++;
+         } else {
+             // Screen y does not increment
+             /*
+              * Two cases: either we're inside a scrolling region or not.  If
+              * the scrolling region bottom is the bottom of the screen, then
+              * push the top line into the buffer.  Else scroll the scrolling
+              * region up.
+              */
+             if ((scrollRegionBottom == height - 1) && (scrollRegionTop == 0)) {
+                 // We're at the bottom of the scroll region, AND the scroll
+                 // region is the entire screen.
+                 // New line
+                 newDisplayLine();
+             } else {
+                 // We're at the bottom of the scroll region, AND the scroll
+                 // region is NOT the entire screen.
+                 scrollingRegionScrollUp(scrollRegionTop, scrollRegionBottom, 1);
+             }
+         }
+         if (newLineMode) {
+             currentState.cursorX = 0;
+         }
+         wrapLineFlag = false;
+     }
+     /**
+      * Prints one character to the display buffer.
+      *
+      * @param ch character to display
+      */
+     private void printCharacter(final int ch) {
+         int rightMargin = this.rightMargin;
+         if (StringUtils.width(ch) == 2) {
+             // This is a full-width character.  Save two spaces, and then
+             // draw the character as two image halves.
+             int x0 = currentState.cursorX;
+             int y0 = currentState.cursorY;
+             printCharacter(' ');
+             printCharacter(' ');
+             if ((currentState.cursorX == x0 + 2)
+                 && (currentState.cursorY == y0)
+             ) {
+                 // We can draw both halves of the character.
+                 drawHalves(x0, y0, x0 + 1, y0, ch);
+             } else if ((currentState.cursorX == x0 + 1)
+                 && (currentState.cursorY == y0)
+             ) {
+                 // VT100 line wrap behavior: we should be at the right
+                 // margin.  We can draw both halves of the character.
+                 drawHalves(x0, y0, x0 + 1, y0, ch);
+             } else {
+                 // The character splits across the line.  Draw the entire
+                 // character on the new line, giving one more space for it.
+                 x0 = currentState.cursorX - 1;
+                 y0 = currentState.cursorY;
+                 printCharacter(' ');
+                 drawHalves(x0, y0, x0 + 1, y0, ch);
+             }
+             return;
+         }
+         // Check if we have double-width, and if so chop at 40/66 instead of
+         // 80/132
+         if (display.get(currentState.cursorY).isDoubleWidth()) {
+             rightMargin = ((rightMargin + 1) / 2) - 1;
+         }
+         // Check the unusually-complicated line wrapping conditions...
+         if (currentState.cursorX == rightMargin) {
+             if (currentState.lineWrap == true) {
+                 /*
+                  * This case happens when: the cursor was already on the
+                  * right margin (either through printing or by an explicit
+                  * placement command), and a character was printed.
+                  *
+                  * The line wraps only when a new character arrives AND the
+                  * cursor is already on the right margin AND has placed a
+                  * character in its cell.  Easier to see than to explain.
+                  */
+                 if (wrapLineFlag == false) {
+                     /*
+                      * This block marks the case that we are in the margin
+                      * and the first character has been received and printed.
+                      */
+                     wrapLineFlag = true;
+                 } else {
+                     /*
+                      * This block marks the case that we are in the margin
+                      * and the second character has been received and
+                      * printed.
+                      */
+                     wrapLineFlag = false;
+                     wrapCurrentLine();
+                 }
+             }
+         } else if (currentState.cursorX <= rightMargin) {
+             /*
+              * This is the normal case: a character came in and was printed
+              * to the left of the right margin column.
+              */
+             // Turn off VT100 special-case flag
+             wrapLineFlag = false;
+         }
+         // "Print" the character
+         Cell newCell = new Cell(ch);
+         CellAttributes newCellAttributes = (CellAttributes) newCell;
+         newCellAttributes.setTo(currentState.attr);
+         DisplayLine line = display.get(currentState.cursorY);
+         if (StringUtils.width(ch) == 1) {
+             // Insert mode special case
+             if (insertMode == true) {
+                 line.insert(currentState.cursorX, newCell);
+             } else {
+                 // Replace an existing character
+                 line.replace(currentState.cursorX, newCell);
+             }
+             // Increment horizontal
+             if (wrapLineFlag == false) {
+                 currentState.cursorX++;
+                 if (currentState.cursorX > rightMargin) {
+                     currentState.cursorX--;
+                 }
+             }
+         }
+     }
+     /**
+      * Translate the mouse event to a VT100, VT220, or XTERM sequence and
+      * send to the remote side.
+      *
+      * @param mouse mouse event received from the local user
+      */
+     private void mouse(final TMouseEvent mouse) {
+         /*
+         System.err.printf("mouse(): protocol %s encoding %s mouse %s\n",
+             mouseProtocol, mouseEncoding, mouse);
+          */
+         if (mouseEncoding == MouseEncoding.X10) {
+             // We will support X10 but only for (160,94) and smaller.
+             if ((mouse.getX() >= 160) || (mouse.getY() >= 94)) {
+                 return;
+             }
+         }
+         switch (mouseProtocol) {
+         case OFF:
+             // Do nothing
+             return;
+         case X10:
+             // Only report button presses
+             if (mouse.getType() != TMouseEvent.Type.MOUSE_DOWN) {
+                 return;
+             }
+             break;
+         case NORMAL:
+             // Only report button presses and releases
+             if ((mouse.getType() != TMouseEvent.Type.MOUSE_DOWN)
+                 && (mouse.getType() != TMouseEvent.Type.MOUSE_UP)
+             ) {
+                 return;
+             }
+             break;
+         case BUTTONEVENT:
+             /*
+              * Only report button presses, button releases, and motions that
+              * have a button down (i.e. drag-and-drop).
+              */
+             if (mouse.getType() == TMouseEvent.Type.MOUSE_MOTION) {
+                 if (!mouse.isMouse1()
+                     && !mouse.isMouse2()
+                     && !mouse.isMouse3()
+                     && !mouse.isMouseWheelUp()
+                     && !mouse.isMouseWheelDown()
+                 ) {
+                     return;
+                 }
+             }
+             break;
+         case ANYEVENT:
+             // Report everything
+             break;
+         }
+         // Now encode the event
+         StringBuilder sb = new StringBuilder(6);
+         if (mouseEncoding == MouseEncoding.SGR) {
+             sb.append((char) 0x1B);
+             sb.append("[<");
+             if (mouse.isMouse1()) {
+                 if (mouse.getType() == TMouseEvent.Type.MOUSE_MOTION) {
+                     sb.append("32;");
+                 } else {
+                     sb.append("0;");
+                 }
+             } else if (mouse.isMouse2()) {
+                 if (mouse.getType() == TMouseEvent.Type.MOUSE_MOTION) {
+                     sb.append("33;");
+                 } else {
+                     sb.append("1;");
+                 }
+             } else if (mouse.isMouse3()) {
+                 if (mouse.getType() == TMouseEvent.Type.MOUSE_MOTION) {
+                     sb.append("34;");
+                 } else {
+                     sb.append("2;");
+                 }
+             } else if (mouse.isMouseWheelUp()) {
+                 sb.append("64;");
+             } else if (mouse.isMouseWheelDown()) {
+                 sb.append("65;");
+             } else {
+                 // This is motion with no buttons down.
+                 sb.append("35;");
+             }
+             sb.append(String.format("%d;%d", mouse.getX() + 1,
+                     mouse.getY() + 1));
+             if (mouse.getType() == TMouseEvent.Type.MOUSE_UP) {
+                 sb.append("m");
+             } else {
+                 sb.append("M");
+             }
+         } else {
+             // X10 and UTF8 encodings
+             sb.append((char) 0x1B);
+             sb.append('[');
+             sb.append('M');
+             if (mouse.getType() == TMouseEvent.Type.MOUSE_UP) {
+                 sb.append((char) (0x03 + 32));
+             } else if (mouse.isMouse1()) {
+                 if (mouse.getType() == TMouseEvent.Type.MOUSE_MOTION) {
+                     sb.append((char) (0x00 + 32 + 32));
+                 } else {
+                     sb.append((char) (0x00 + 32));
+                 }
+             } else if (mouse.isMouse2()) {
+                 if (mouse.getType() == TMouseEvent.Type.MOUSE_MOTION) {
+                     sb.append((char) (0x01 + 32 + 32));
+                 } else {
+                     sb.append((char) (0x01 + 32));
+                 }
+             } else if (mouse.isMouse3()) {
+                 if (mouse.getType() == TMouseEvent.Type.MOUSE_MOTION) {
+                     sb.append((char) (0x02 + 32 + 32));
+                 } else {
+                     sb.append((char) (0x02 + 32));
+                 }
+             } else if (mouse.isMouseWheelUp()) {
+                 sb.append((char) (0x04 + 64));
+             } else if (mouse.isMouseWheelDown()) {
+                 sb.append((char) (0x05 + 64));
+             } else {
+                 // This is motion with no buttons down.
+                 sb.append((char) (0x03 + 32));
+             }
+             sb.append((char) (mouse.getX() + 33));
+             sb.append((char) (mouse.getY() + 33));
+         }
+         // System.err.printf("Would write: \'%s\'\n", sb.toString());
+         writeRemote(sb.toString());
+     }
+     /**
+      * Translate the keyboard press to a VT100, VT220, or XTERM sequence and
+      * send to the remote side.
+      *
+      * @param keypress keypress received from the local user
+      */
+     private void keypress(final TKeypress keypress) {
+         writeRemote(keypressToString(keypress));
+     }
+     /**
+      * Build one of the complex xterm keystroke sequences, storing the result in
+      * xterm_keystroke_buffer.
+      *
+      * @param ss3 the prefix to use based on VT100 state.
+      * @param first the first character, usually a number.
+      * @param first the last character, one of the following: ~ A B C D F H
+      * @param ctrl whether or not ctrl is down
+      * @param alt whether or not alt is down
+      * @param shift whether or not shift is down
+      * @return the buffer with the full key sequence
+      */
+     private String xtermBuildKeySequence(final String ss3, final char first,
+         final char last, boolean ctrl, boolean alt, boolean shift) {
+         StringBuilder sb = new StringBuilder(ss3);
+         if ((last == '~') || (ctrl == true) || (alt == true)
+             || (shift == true)
+         ) {
+             sb.append(first);
+             if (       (ctrl == false) && (alt == false) && (shift == true)) {
+                 sb.append(";2");
+             } else if ((ctrl == false) && (alt == true) && (shift == false)) {
+                 sb.append(";3");
+             } else if ((ctrl == false) && (alt == true) && (shift == true)) {
+                 sb.append(";4");
+             } else if ((ctrl == true) && (alt == false) && (shift == false)) {
+                 sb.append(";5");
+             } else if ((ctrl == true) && (alt == false) && (shift == true)) {
+                 sb.append(";6");
+             } else if ((ctrl == true) && (alt == true) && (shift == false)) {
+                 sb.append(";7");
+             } else if ((ctrl == true) && (alt == true) && (shift == true)) {
+                 sb.append(";8");
+             }
+         }
+         sb.append(last);
+         return sb.toString();
+     }
+     /**
+      * Translate the keyboard press to a VT100, VT220, or XTERM sequence.
+      *
+      * @param keypress keypress received from the local user
+      * @return string to transmit to the remote side
+      */
+     @SuppressWarnings("fallthrough")
+     private String keypressToString(final TKeypress keypress) {
+         if ((fullDuplex == false) && (!keypress.isFnKey())) {
+             /*
+              * If this is a control character, process it like it came from
+              * the remote side.
+              */
+             if (keypress.getChar() < 0x20) {
+                 handleControlChar((char) keypress.getChar());
+             } else {
+                 // Local echo for everything else
+                 printCharacter(keypress.getChar());
+             }
+             if (displayListener != null) {
+                 displayListener.displayChanged();
+             }
+         }
+         if ((newLineMode == true) && (keypress.equals(kbEnter))) {
+             // NLM: send CRLF
+             return "\015\012";
+         }
+         // Handle control characters
+         if ((keypress.isCtrl()) && (!keypress.isFnKey())) {
+             StringBuilder sb = new StringBuilder();
+             int ch = keypress.getChar();
+             ch -= 0x40;
+             sb.append(Character.toChars(ch));
+             return sb.toString();
+         }
+         // Handle alt characters
+         if ((keypress.isAlt()) && (!keypress.isFnKey())) {
+             StringBuilder sb = new StringBuilder("\033");
+             int ch = keypress.getChar();
+             sb.append(Character.toChars(ch));
+             return sb.toString();
+         }
+         if (keypress.equals(kbBackspaceDel)) {
+             switch (type) {
+             case VT100:
+                 return "\010";
+             case VT102:
+                 return "\010";
+             case VT220:
+                 return "\177";
+             case XTERM:
+                 return "\177";
+             }
+         }
+         if (keypress.equalsWithoutModifiers(kbLeft)) {
+             switch (type) {
+             case XTERM:
+                 switch (arrowKeyMode) {
+                 case ANSI:
+                     return xtermBuildKeySequence("\033[", '1', 'D',
+                         keypress.isCtrl(), keypress.isAlt(),
+                         keypress.isShift());
+                 case VT52:
+                     return xtermBuildKeySequence("\033", '1', 'D',
+                         keypress.isCtrl(), keypress.isAlt(),
+                         keypress.isShift());
+                 case VT100:
+                     return xtermBuildKeySequence("\033O", '1', 'D',
+                         keypress.isCtrl(), keypress.isAlt(),
+                         keypress.isShift());
+                 }
+             default:
+                 switch (arrowKeyMode) {
+                 case ANSI:
+                     return "\033[D";
+                 case VT52:
+                     return "\033D";
+                 case VT100:
+                     return "\033OD";
+                 }
+             }
+         }
+         if (keypress.equalsWithoutModifiers(kbRight)) {
+             switch (type) {
+             case XTERM:
+                 switch (arrowKeyMode) {
+                 case ANSI:
+                     return xtermBuildKeySequence("\033[", '1', 'C',
+                         keypress.isCtrl(), keypress.isAlt(),
+                         keypress.isShift());
+                 case VT52:
+                     return xtermBuildKeySequence("\033", '1', 'C',
+                         keypress.isCtrl(), keypress.isAlt(),
+                         keypress.isShift());
+                 case VT100:
+                     return xtermBuildKeySequence("\033O", '1', 'C',
+                         keypress.isCtrl(), keypress.isAlt(),
+                         keypress.isShift());
+                 }
+             default:
+                 switch (arrowKeyMode) {
+                 case ANSI:
+                     return "\033[C";
+                 case VT52:
+                     return "\033C";
+                 case VT100:
+                     return "\033OC";
+                 }
+             }
+         }
+         if (keypress.equalsWithoutModifiers(kbUp)) {
+             switch (type) {
+             case XTERM:
+                 switch (arrowKeyMode) {
+                 case ANSI:
+                     return xtermBuildKeySequence("\033[", '1', 'A',
+                         keypress.isCtrl(), keypress.isAlt(),
+                         keypress.isShift());
+                 case VT52:
+                     return xtermBuildKeySequence("\033", '1', 'A',
+                         keypress.isCtrl(), keypress.isAlt(),
+                         keypress.isShift());
+                 case VT100:
+                     return xtermBuildKeySequence("\033O", '1', 'A',
+                         keypress.isCtrl(), keypress.isAlt(),
+                         keypress.isShift());
+                 }
+             default:
+                 switch (arrowKeyMode) {
+                 case ANSI:
+                     return "\033[A";
+                 case VT52:
+                     return "\033A";
+                 case VT100:
+                     return "\033OA";
+                 }
+             }
+         }
+         if (keypress.equalsWithoutModifiers(kbDown)) {
+             switch (type) {
+             case XTERM:
+                 switch (arrowKeyMode) {
+                 case ANSI:
+                     return xtermBuildKeySequence("\033[", '1', 'B',
+                         keypress.isCtrl(), keypress.isAlt(),
+                         keypress.isShift());
+                 case VT52:
+                     return xtermBuildKeySequence("\033", '1', 'B',
+                         keypress.isCtrl(), keypress.isAlt(),
+                         keypress.isShift());
+                 case VT100:
+                     return xtermBuildKeySequence("\033O", '1', 'B',
+                         keypress.isCtrl(), keypress.isAlt(),
+                         keypress.isShift());
+                 }
+             default:
+                 switch (arrowKeyMode) {
+                 case ANSI:
+                     return "\033[B";
+                 case VT52:
+                     return "\033B";
+                 case VT100:
+                     return "\033OB";
+                 }
+             }
+         }
+         if (keypress.equalsWithoutModifiers(kbHome)) {
+             switch (type) {
+             case XTERM:
+                 switch (arrowKeyMode) {
+                 case ANSI:
+                     return xtermBuildKeySequence("\033[", '1', 'H',
+                         keypress.isCtrl(), keypress.isAlt(),
+                         keypress.isShift());
+                 case VT52:
+                     return xtermBuildKeySequence("\033", '1', 'H',
+                         keypress.isCtrl(), keypress.isAlt(),
+                         keypress.isShift());
+                 case VT100:
+                     return xtermBuildKeySequence("\033O", '1', 'H',
+                         keypress.isCtrl(), keypress.isAlt(),
+                         keypress.isShift());
+                 }
+             default:
+                 switch (arrowKeyMode) {
+                 case ANSI:
+                     return "\033[H";
+                 case VT52:
+                     return "\033H";
+                 case VT100:
+                     return "\033OH";
+                 }
+             }
+         }
+         if (keypress.equalsWithoutModifiers(kbEnd)) {
+             switch (type) {
+             case XTERM:
+                 switch (arrowKeyMode) {
+                 case ANSI:
+                     return xtermBuildKeySequence("\033[", '1', 'F',
+                         keypress.isCtrl(), keypress.isAlt(),
+                         keypress.isShift());
+                 case VT52:
+                     return xtermBuildKeySequence("\033", '1', 'F',
+                         keypress.isCtrl(), keypress.isAlt(),
+                         keypress.isShift());
+                 case VT100:
+                     return xtermBuildKeySequence("\033O", '1', 'F',
+                         keypress.isCtrl(), keypress.isAlt(),
+                         keypress.isShift());
+                 }
+             default:
+                 switch (arrowKeyMode) {
+                 case ANSI:
+                     return "\033[F";
+                 case VT52:
+                     return "\033F";
+                 case VT100:
+                     return "\033OF";
+                 }
+             }
+         }
+         if (keypress.equals(kbF1)) {
+             // PF1
+             if (vt52Mode) {
+                 return "\033P";
+             }
+             return "\033OP";
+         }
+         if (keypress.equals(kbF2)) {
+             // PF2
+             if (vt52Mode) {
+                 return "\033Q";
+             }
+             return "\033OQ";
+         }
+         if (keypress.equals(kbF3)) {
+             // PF3
+             if (vt52Mode) {
+                 return "\033R";
+             }
+             return "\033OR";
+         }
+         if (keypress.equals(kbF4)) {
+             // PF4
+             if (vt52Mode) {
+                 return "\033S";
+             }
+             return "\033OS";
+         }
+         if (keypress.equals(kbF5)) {
+             switch (type) {
+             case VT100:
+                 return "\033Ot";
+             case VT102:
+                 return "\033Ot";
+             case VT220:
+                 return "\033[15~";
+             case XTERM:
+                 return "\033[15~";
+             }
+         }
+         if (keypress.equals(kbF6)) {
+             switch (type) {
+             case VT100:
+                 return "\033Ou";
+             case VT102:
+                 return "\033Ou";
+             case VT220:
+                 return "\033[17~";
+             case XTERM:
+                 return "\033[17~";
+             }
+         }
+         if (keypress.equals(kbF7)) {
+             switch (type) {
+             case VT100:
+                 return "\033Ov";
+             case VT102:
+                 return "\033Ov";
+             case VT220:
+                 return "\033[18~";
+             case XTERM:
+                 return "\033[18~";
+             }
+         }
+         if (keypress.equals(kbF8)) {
+             switch (type) {
+             case VT100:
+                 return "\033Ol";
+             case VT102:
+                 return "\033Ol";
+             case VT220:
+                 return "\033[19~";
+             case XTERM:
+                 return "\033[19~";
+             }
+         }
+         if (keypress.equals(kbF9)) {
+             switch (type) {
+             case VT100:
+                 return "\033Ow";
+             case VT102:
+                 return "\033Ow";
+             case VT220:
+                 return "\033[20~";
+             case XTERM:
+                 return "\033[20~";
+             }
+         }
+         if (keypress.equals(kbF10)) {
+             switch (type) {
+             case VT100:
+                 return "\033Ox";
+             case VT102:
+                 return "\033Ox";
+             case VT220:
+                 return "\033[21~";
+             case XTERM:
+                 return "\033[21~";
+             }
+         }
+         if (keypress.equals(kbF11)) {
+             return "\033[23~";
+         }
+         if (keypress.equals(kbF12)) {
+             return "\033[24~";
+         }
+         if (keypress.equals(kbShiftF1)) {
+             // Shifted PF1
+             if (vt52Mode) {
+                 return "\0332P";
+             }
+             if (type == DeviceType.XTERM) {
+                 return "\0331;2P";
+             }
+             return "\033O2P";
+         }
+         if (keypress.equals(kbShiftF2)) {
+             // Shifted PF2
+             if (vt52Mode) {
+                 return "\0332Q";
+             }
+             if (type == DeviceType.XTERM) {
+                 return "\0331;2Q";
+             }
+             return "\033O2Q";
+         }
+         if (keypress.equals(kbShiftF3)) {
+             // Shifted PF3
+             if (vt52Mode) {
+                 return "\0332R";
+             }
+             if (type == DeviceType.XTERM) {
+                 return "\0331;2R";
+             }
+             return "\033O2R";
+         }
+         if (keypress.equals(kbShiftF4)) {
+             // Shifted PF4
+             if (vt52Mode) {
+                 return "\0332S";
+             }
+             if (type == DeviceType.XTERM) {
+                 return "\0331;2S";
+             }
+             return "\033O2S";
+         }
+         if (keypress.equals(kbShiftF5)) {
+             // Shifted F5
+             return "\033[15;2~";
+         }
+         if (keypress.equals(kbShiftF6)) {
+             // Shifted F6
+             return "\033[17;2~";
+         }
+         if (keypress.equals(kbShiftF7)) {
+             // Shifted F7
+             return "\033[18;2~";
+         }
+         if (keypress.equals(kbShiftF8)) {
+             // Shifted F8
+             return "\033[19;2~";
+         }
+         if (keypress.equals(kbShiftF9)) {
+             // Shifted F9
+             return "\033[20;2~";
+         }
+         if (keypress.equals(kbShiftF10)) {
+             // Shifted F10
+             return "\033[21;2~";
+         }
+         if (keypress.equals(kbShiftF11)) {
+             // Shifted F11
+             return "\033[23;2~";
+         }
+         if (keypress.equals(kbShiftF12)) {
+             // Shifted F12
+             return "\033[24;2~";
+         }
+         if (keypress.equals(kbCtrlF1)) {
+             // Control PF1
+             if (vt52Mode) {
+                 return "\0335P";
+             }
+             if (type == DeviceType.XTERM) {
+                 return "\0331;5P";
+             }
+             return "\033O5P";
+         }
+         if (keypress.equals(kbCtrlF2)) {
+             // Control PF2
+             if (vt52Mode) {
+                 return "\0335Q";
+             }
+             if (type == DeviceType.XTERM) {
+                 return "\0331;5Q";
+             }
+             return "\033O5Q";
+         }
+         if (keypress.equals(kbCtrlF3)) {
+             // Control PF3
+             if (vt52Mode) {
+                 return "\0335R";
+             }
+             if (type == DeviceType.XTERM) {
+                 return "\0331;5R";
+             }
+             return "\033O5R";
+         }
+         if (keypress.equals(kbCtrlF4)) {
+             // Control PF4
+             if (vt52Mode) {
+                 return "\0335S";
+             }
+             if (type == DeviceType.XTERM) {
+                 return "\0331;5S";
+             }
+             return "\033O5S";
+         }
+         if (keypress.equals(kbCtrlF5)) {
+             // Control F5
+             return "\033[15;5~";
+         }
+         if (keypress.equals(kbCtrlF6)) {
+             // Control F6
+             return "\033[17;5~";
+         }
+         if (keypress.equals(kbCtrlF7)) {
+             // Control F7
+             return "\033[18;5~";
+         }
+         if (keypress.equals(kbCtrlF8)) {
+             // Control F8
+             return "\033[19;5~";
+         }
+         if (keypress.equals(kbCtrlF9)) {
+             // Control F9
+             return "\033[20;5~";
+         }
+         if (keypress.equals(kbCtrlF10)) {
+             // Control F10
+             return "\033[21;5~";
+         }
+         if (keypress.equals(kbCtrlF11)) {
+             // Control F11
+             return "\033[23;5~";
+         }
+         if (keypress.equals(kbCtrlF12)) {
+             // Control F12
+             return "\033[24;5~";
+         }
+         if (keypress.equalsWithoutModifiers(kbPgUp)) {
+             switch (type) {
+             case XTERM:
+                 return xtermBuildKeySequence("\033[", '5', '~',
+                     keypress.isCtrl(), keypress.isAlt(),
+                     keypress.isShift());
+             default:
+                 return "\033[5~";
+             }
+         }
+         if (keypress.equalsWithoutModifiers(kbPgDn)) {
+             switch (type) {
+             case XTERM:
+                 return xtermBuildKeySequence("\033[", '6', '~',
+                     keypress.isCtrl(), keypress.isAlt(),
+                     keypress.isShift());
+             default:
+                 return "\033[6~";
+             }
+         }
+         if (keypress.equalsWithoutModifiers(kbIns)) {
+             switch (type) {
+             case XTERM:
+                 return xtermBuildKeySequence("\033[", '2', '~',
+                     keypress.isCtrl(), keypress.isAlt(),
+                     keypress.isShift());
+             default:
+                 return "\033[2~";
+             }
+         }
+         if (keypress.equalsWithoutModifiers(kbDel)) {
+             switch (type) {
+             case XTERM:
+                 return xtermBuildKeySequence("\033[", '3', '~',
+                     keypress.isCtrl(), keypress.isAlt(),
+                     keypress.isShift());
+             default:
+                 // Delete sends real delete for VTxxx
+                 return "\177";
+             }
+         }
+         if (keypress.equals(kbEnter)) {
+             return "\015";
+         }
+         if (keypress.equals(kbEsc)) {
+             return "\033";
+         }
+         if (keypress.equals(kbAltEsc)) {
+             return "\033\033";
+         }
+         if (keypress.equals(kbTab)) {
+             return "\011";
+         }
+         if ((keypress.equalsWithoutModifiers(kbBackTab)) ||
+             (keypress.equals(kbShiftTab))
+         ) {
+             switch (type) {
+             case XTERM:
+                 return "\033[Z";
+             default:
+                 return "\011";
+             }
+         }
+         // Non-alt, non-ctrl characters
+         if (!keypress.isFnKey()) {
+             StringBuilder sb = new StringBuilder();
+             sb.append(Character.toChars(keypress.getChar()));
+             return sb.toString();
+         }
+         return "";
+     }
+     /**
+      * Map a symbol in any one of the VT100/VT220 character sets to a Unicode
+      * symbol.
+      *
+      * @param ch 8-bit character from the remote side
+      * @param charsetGl character set defined for GL
+      * @param charsetGr character set defined for GR
+      * @return character to display on the screen
+      */
+     private char mapCharacterCharset(final int ch,
+         final CharacterSet charsetGl,
+         final CharacterSet charsetGr) {
+         int lookupChar = ch;
+         CharacterSet lookupCharset = charsetGl;
+         if (ch >= 0x80) {
+             assert ((type == DeviceType.VT220) || (type == DeviceType.XTERM));
+             lookupCharset = charsetGr;
+             lookupChar &= 0x7F;
+         }
+         switch (lookupCharset) {
+         case DRAWING:
+             return DECCharacterSets.SPECIAL_GRAPHICS[lookupChar];
+         case UK:
+             return DECCharacterSets.UK[lookupChar];
+         case US:
+             return DECCharacterSets.US_ASCII[lookupChar];
+         case NRC_DUTCH:
+             return DECCharacterSets.NL[lookupChar];
+         case NRC_FINNISH:
+             return DECCharacterSets.FI[lookupChar];
+         case NRC_FRENCH:
+             return DECCharacterSets.FR[lookupChar];
+         case NRC_FRENCH_CA:
+             return DECCharacterSets.FR_CA[lookupChar];
+         case NRC_GERMAN:
+             return DECCharacterSets.DE[lookupChar];
+         case NRC_ITALIAN:
+             return DECCharacterSets.IT[lookupChar];
+         case NRC_NORWEGIAN:
+             return DECCharacterSets.NO[lookupChar];
+         case NRC_SPANISH:
+             return DECCharacterSets.ES[lookupChar];
+         case NRC_SWEDISH:
+             return DECCharacterSets.SV[lookupChar];
+         case NRC_SWISS:
+             return DECCharacterSets.SWISS[lookupChar];
+         case DEC_SUPPLEMENTAL:
+             return DECCharacterSets.DEC_SUPPLEMENTAL[lookupChar];
+         case VT52_GRAPHICS:
+             return DECCharacterSets.VT52_SPECIAL_GRAPHICS[lookupChar];
+         case ROM:
+             return DECCharacterSets.US_ASCII[lookupChar];
+         case ROM_SPECIAL:
+             return DECCharacterSets.US_ASCII[lookupChar];
+         default:
+             throw new IllegalArgumentException("Invalid character set value: "
+                 + lookupCharset);
+         }
+     }
+     /**
+      * Map an 8-bit byte into a printable character.
+      *
+      * @param ch either 8-bit or Unicode character from the remote side
+      * @return character to display on the screen
+      */
+     private int mapCharacter(final int ch) {
+         if (ch >= 0x100) {
+             // Unicode character, just return it
+             return ch;
+         }
+         CharacterSet charsetGl = currentState.g0Charset;
+         CharacterSet charsetGr = currentState.grCharset;
+         if (vt52Mode == true) {
+             if (shiftOut == true) {
+                 // Shifted out character, pull from VT52 graphics
+                 charsetGl = currentState.g1Charset;
+                 charsetGr = CharacterSet.US;
+             } else {
+                 // Normal
+                 charsetGl = currentState.g0Charset;
+                 charsetGr = CharacterSet.US;
+             }
+             // Pull the character
+             return mapCharacterCharset(ch, charsetGl, charsetGr);
+         }
+         // shiftOout
+         if (shiftOut == true) {
+             // Shifted out character, pull from G1
+             charsetGl = currentState.g1Charset;
+             charsetGr = currentState.grCharset;
+             // Pull the character
+             return mapCharacterCharset(ch, charsetGl, charsetGr);
+         }
+         // SS2
+         if (singleshift == Singleshift.SS2) {
+             singleshift = Singleshift.NONE;
+             // Shifted out character, pull from G2
+             charsetGl = currentState.g2Charset;
+             charsetGr = currentState.grCharset;
+         }
+         // SS3
+         if (singleshift == Singleshift.SS3) {
+             singleshift = Singleshift.NONE;
+             // Shifted out character, pull from G3
+             charsetGl = currentState.g3Charset;
+             charsetGr = currentState.grCharset;
+         }
+         if ((type == DeviceType.VT220) || (type == DeviceType.XTERM)) {
+             // Check for locking shift
+             switch (currentState.glLockshift) {
+             case G1_GR:
+                 throw new IllegalArgumentException("programming bug");
+             case G2_GR:
+                 throw new IllegalArgumentException("programming bug");
+             case G3_GR:
+                 throw new IllegalArgumentException("programming bug");
+             case G2_GL:
+                 // LS2
+                 charsetGl = currentState.g2Charset;
+                 break;
+             case G3_GL:
+                 // LS3
+                 charsetGl = currentState.g3Charset;
+                 break;
+             case NONE:
+                 // Normal
+                 charsetGl = currentState.g0Charset;
+                 break;
+             }
+             switch (currentState.grLockshift) {
+             case G2_GL:
+                 throw new IllegalArgumentException("programming bug");
+             case G3_GL:
+                 throw new IllegalArgumentException("programming bug");
+             case G1_GR:
+                 // LS1R
+                 charsetGr = currentState.g1Charset;
+                 break;
+             case G2_GR:
+                 // LS2R
+                 charsetGr = currentState.g2Charset;
+                 break;
+             case G3_GR:
+                 // LS3R
+                 charsetGr = currentState.g3Charset;
+                 break;
+             case NONE:
+                 // Normal
+                 charsetGr = CharacterSet.DEC_SUPPLEMENTAL;
+                 break;
+             }
+         }
+         // Pull the character
+         return mapCharacterCharset(ch, charsetGl, charsetGr);
+     }
+     /**
+      * Scroll the text within a scrolling region up n lines.
+      *
+      * @param regionTop top row of the scrolling region
+      * @param regionBottom bottom row of the scrolling region
+      * @param n number of lines to scroll
+      */
+     private void scrollingRegionScrollUp(final int regionTop,
+         final int regionBottom, final int n) {
+         if (regionTop >= regionBottom) {
+             return;
+         }
+         // Sanity check: see if there will be any characters left after the
+         // scroll
+         if (regionBottom + 1 - regionTop <= n) {
+             // There won't be anything left in the region, so just call
+             // eraseScreen() and return.
+             eraseScreen(regionTop, 0, regionBottom, width - 1, false);
+             return;
+         }
+         int remaining = regionBottom + 1 - regionTop - n;
+         List<DisplayLine> displayTop = display.subList(0, regionTop);
+         List<DisplayLine> displayBottom = display.subList(regionBottom + 1,
+             display.size());
+         List<DisplayLine> displayMiddle = display.subList(regionBottom + 1
+             - remaining, regionBottom + 1);
+         display = new ArrayList<DisplayLine>(displayTop);
+         display.addAll(displayMiddle);
+         for (int i = 0; i < n; i++) {
+             DisplayLine line = new DisplayLine(currentState.attr);
+             line.setReverseColor(reverseVideo);
+             display.add(line);
+         }
+         display.addAll(displayBottom);
+         assert (display.size() == height);
+     }
+     /**
+      * Scroll the text within a scrolling region down n lines.
+      *
+      * @param regionTop top row of the scrolling region
+      * @param regionBottom bottom row of the scrolling region
+      * @param n number of lines to scroll
+      */
+     private void scrollingRegionScrollDown(final int regionTop,
+         final int regionBottom, final int n) {
+         if (regionTop >= regionBottom) {
+             return;
+         }
+         // Sanity check: see if there will be any characters left after the
+         // scroll
+         if (regionBottom + 1 - regionTop <= n) {
+             // There won't be anything left in the region, so just call
+             // eraseScreen() and return.
+             eraseScreen(regionTop, 0, regionBottom, width - 1, false);
+             return;
+         }
+         int remaining = regionBottom + 1 - regionTop - n;
+         List<DisplayLine> displayTop = display.subList(0, regionTop);
+         List<DisplayLine> displayBottom = display.subList(regionBottom + 1,
+             display.size());
+         List<DisplayLine> displayMiddle = display.subList(regionTop,
+             regionTop + remaining);
+         display = new ArrayList<DisplayLine>(displayTop);
+         for (int i = 0; i < n; i++) {
+             DisplayLine line = new DisplayLine(currentState.attr);
+             line.setReverseColor(reverseVideo);
+             display.add(line);
+         }
+         display.addAll(displayMiddle);
+         display.addAll(displayBottom);
+         assert (display.size() == height);
+     }
+     /**
+      * Process a control character.
+      *
+      * @param ch 8-bit character from the remote side
+      */
+     private void handleControlChar(final char ch) {
+         assert ((ch <= 0x1F) || ((ch >= 0x7F) && (ch <= 0x9F)));
+         switch (ch) {
+         case 0x00:
+             // NUL - discard
+             return;
+         case 0x05:
+             // ENQ
+             // Transmit the answerback message.
+             // Not supported
+             break;
+         case 0x07:
+             // BEL
+             // Not supported
+             break;
+         case 0x08:
+             // BS
+             cursorLeft(1, false);
+             break;
+         case 0x09:
+             // HT
+             advanceToNextTabStop();
+             break;
+         case 0x0A:
+             // LF
+             linefeed();
+             break;
+         case 0x0B:
+             // VT
+             linefeed();
+             break;
+         case 0x0C:
+             // FF
+             linefeed();
+             break;
+         case 0x0D:
+             // CR
+             carriageReturn();
+             break;
+         case 0x0E:
+             // SO
+             shiftOut = true;
+             currentState.glLockshift = LockshiftMode.NONE;
+             break;
+         case 0x0F:
+             // SI
+             shiftOut = false;
+             currentState.glLockshift = LockshiftMode.NONE;
+             break;
+         case 0x84:
+             // IND
+             ind();
+             break;
+         case 0x85:
+             // NEL
+             nel();
+             break;
+         case 0x88:
+             // HTS
+             hts();
+             break;
+         case 0x8D:
+             // RI
+             ri();
+             break;
+         case 0x8E:
+             // SS2
+             singleshift = Singleshift.SS2;
+             break;
+         case 0x8F:
+             // SS3
+             singleshift = Singleshift.SS3;
+             break;
+         default:
+             break;
+         }
+     }
+     /**
+      * Advance the cursor to the next tab stop.
+      */
+     private void advanceToNextTabStop() {
+         if (tabStops.size() == 0) {
+             // Go to the rightmost column
+             cursorRight(rightMargin - currentState.cursorX, false);
+             return;
+         }
+         for (Integer stop: tabStops) {
+             if (stop > currentState.cursorX) {
+                 cursorRight(stop - currentState.cursorX, false);
+                 return;
+             }
+         }
+         /*
+          * We got here, meaning there isn't a tab stop beyond the current
+          * cursor position.  Place the cursor of the right-most edge of the
+          * screen.
+          */
+         cursorRight(rightMargin - currentState.cursorX, false);
+     }
+     /**
+      * Save a character into the collect buffer.
+      *
+      * @param ch character to save
+      */
+     private void collect(final char ch) {
+         collectBuffer.append(ch);
+     }
+     /**
+      * Save a byte into the CSI parameters buffer.
+      *
+      * @param ch byte to save
+      */
+     private void param(final byte ch) {
+         if (csiParams.size() == 0) {
+             csiParams.add(Integer.valueOf(0));
+         }
+         Integer x = csiParams.get(csiParams.size() - 1);
+         if ((ch >= '0') && (ch <= '9')) {
+             x *= 10;
+             x += (ch - '0');
+             csiParams.set(csiParams.size() - 1, x);
+         }
+         if ((ch == ';') && (csiParams.size() < 16)) {
+             csiParams.add(Integer.valueOf(0));
+         }
+     }
+     /**
+      * Get a CSI parameter value, with a default.
+      *
+      * @param position parameter index.  0 is the first parameter.
+      * @param defaultValue value to use if csiParams[position] doesn't exist
+      * @return parameter value
+      */
+     private int getCsiParam(final int position, final int defaultValue) {
+         if (csiParams.size() < position + 1) {
+             return defaultValue;
+         }
+         return csiParams.get(position).intValue();
+     }
+     /**
+      * Get a CSI parameter value, clamped to within min/max.
+      *
+      * @param position parameter index.  0 is the first parameter.
+      * @param defaultValue value to use if csiParams[position] doesn't exist
+      * @param minValue minimum value inclusive
+      * @param maxValue maximum value inclusive
+      * @return parameter value
+      */
+     private int getCsiParam(final int position, final int defaultValue,
+         final int minValue, final int maxValue) {
+         assert (minValue <= maxValue);
+         int value = getCsiParam(position, defaultValue);
+         if (value < minValue) {
+             value = minValue;
+         }
+         if (value > maxValue) {
+             value = maxValue;
+         }
+         return value;
+     }
+     /**
+      * Set or unset a toggle.
+      *
+      * @param value true for set ('h'), false for reset ('l')
+      */
+     private void setToggle(final boolean value) {
+         boolean decPrivateModeFlag = false;
+         for (int i = 0; i < collectBuffer.length(); i++) {
+             if (collectBuffer.charAt(i) == '?') {
+                 decPrivateModeFlag = true;
+                 break;
+             }
+         }
+         for (Integer i: csiParams) {
+             switch (i) {
+             case 1:
+                 if (decPrivateModeFlag == true) {
+                     // DECCKM
+                     if (value == true) {
+                         // Use application arrow keys
+                         arrowKeyMode = ArrowKeyMode.VT100;
+                     } else {
+                         // Use ANSI arrow keys
+                         arrowKeyMode = ArrowKeyMode.ANSI;
+                     }
+                 }
+                 break;
+             case 2:
+                 if (decPrivateModeFlag == true) {
+                     if (value == false) {
+                         // DECANM
+                         vt52Mode = true;
+                         arrowKeyMode = ArrowKeyMode.VT52;
+                         /*
+                          * From the VT102 docs: "You use ANSI mode to select
+                          * most terminal features; the terminal uses the same
+                          * features when it switches to VT52 mode. You
+                          * cannot, however, change most of these features in
+                          * VT52 mode."
+                          *
+                          * In other words, do not reset any other attributes
+                          * when switching between VT52 submode and ANSI.
+                          *
+                          * HOWEVER, the real vt100 does switch the character
+                          * set according to Usenet.
+                          */
+                         currentState.g0Charset = CharacterSet.US;
+                         currentState.g1Charset = CharacterSet.DRAWING;
+                         shiftOut = false;
+                         if ((type == DeviceType.VT220)
+                             || (type == DeviceType.XTERM)) {
+                             // VT52 mode is explicitly 7-bit
+                             s8c1t = false;
+                             singleshift = Singleshift.NONE;
+                         }
+                     }
+                 } else {
+                     // KAM
+                     if (value == true) {
+                         // Turn off keyboard
+                         // Not supported
+                     } else {
+                         // Turn on keyboard
+                         // Not supported
+                     }
+                 }
+                 break;
+             case 3:
+                 if (decPrivateModeFlag == true) {
+                     // DECCOLM
+                     if (value == true) {
+                         // 132 columns
+                         columns132 = true;
+                         rightMargin = 131;
+                     } else {
+                         // 80 columns
+                         columns132 = false;
+                         if ((displayListener != null)
+                             && (type == DeviceType.XTERM)
+                         ) {
+                             // For xterms, reset to the actual width, not 80
+                             // columns.
+                             width = displayListener.getDisplayWidth();
+                             rightMargin = width - 1;
+                         } else {
+                             rightMargin = 79;
+                             width = rightMargin + 1;
+                         }
+                     }
+                     // Entire screen is cleared, and scrolling region is
+                     // reset
+                     eraseScreen(0, 0, height - 1, width - 1, false);
+                     scrollRegionTop = 0;
+                     scrollRegionBottom = height - 1;
+                     // Also home the cursor
+                     cursorPosition(0, 0);
+                 }
+                 break;
+             case 4:
+                 if (decPrivateModeFlag == true) {
+                     // DECSCLM
+                     if (value == true) {
+                         // Smooth scroll
+                         // Not supported
+                     } else {
+                         // Jump scroll
+                         // Not supported
+                     }
+                 } else {
+                     // IRM
+                     if (value == true) {
+                         insertMode = true;
+                     } else {
+                         insertMode = false;
+                     }
+                 }
+                 break;
+             case 5:
+                 if (decPrivateModeFlag == true) {
+                     // DECSCNM
+                     if (value == true) {
+                         /*
+                          * Set selects reverse screen, a white screen
+                          * background with black characters.
+                          */
+                         if (reverseVideo != true) {
+                             /*
+                              * If in normal video, switch it back
+                              */
+                             invertDisplayColors();
+                         }
+                         reverseVideo = true;
+                     } else {
+                         /*
+                          * Reset selects normal screen, a black screen
+                          * background with white characters.
+                          */
+                         if (reverseVideo == true) {
+                             /*
+                              * If in reverse video already, switch it back
+                              */
+                             invertDisplayColors();
+                         }
+                         reverseVideo = false;
+                     }
+                 }
+                 break;
+             case 6:
+                 if (decPrivateModeFlag == true) {
+                     // DECOM
+                     if (value == true) {
+                         // Origin is relative to scroll region cursor.
+                         // Cursor can NEVER leave scrolling region.
+                         currentState.originMode = true;
+                         cursorPosition(0, 0);
+                     } else {
+                         // Origin is absolute to entire screen.  Cursor can
+                         // leave the scrolling region via cup() and hvp().
+                         currentState.originMode = false;
+                         cursorPosition(0, 0);
+                     }
+                 }
+                 break;
+             case 7:
+                 if (decPrivateModeFlag == true) {
+                     // DECAWM
+                     if (value == true) {
+                         // Turn linewrap on
+                         currentState.lineWrap = true;
+                     } else {
+                         // Turn linewrap off
+                         currentState.lineWrap = false;
+                     }
+                 }
+                 break;
+             case 8:
+                 if (decPrivateModeFlag == true) {
+                     // DECARM
+                     if (value == true) {
+                         // Keyboard auto-repeat on
+                         // Not supported
+                     } else {
+                         // Keyboard auto-repeat off
+                         // Not supported
+                     }
+                 }
+                 break;
+             case 12:
+                 if (decPrivateModeFlag == false) {
+                     // SRM
+                     if (value == true) {
+                         // Local echo off
+                         fullDuplex = true;
+                     } else {
+                         // Local echo on
+                         fullDuplex = false;
+                     }
+                 }
+                 break;
+             case 18:
+                 if (decPrivateModeFlag == true) {
+                     // DECPFF
+                     // Not supported
+                 }
+                 break;
+             case 19:
+                 if (decPrivateModeFlag == true) {
+                     // DECPEX
+                     // Not supported
+                 }
+                 break;
+             case 20:
+                 if (decPrivateModeFlag == false) {
+                     // LNM
+                     if (value == true) {
+                         /*
+                          * Set causes a received linefeed, form feed, or
+                          * vertical tab to move cursor to first column of
+                          * next line. RETURN transmits both a carriage return
+                          * and linefeed. This selection is also called new
+                          * line option.
+                          */
+                         newLineMode = true;
+                     } else {
+                         /*
+                          * Reset causes a received linefeed, form feed, or
+                          * vertical tab to move cursor to next line in
+                          * current column. RETURN transmits a carriage
+                          * return.
+                          */
+                         newLineMode = false;
+                     }
+                 }
+                 break;
+             case 25:
+                 if ((type == DeviceType.VT220) || (type == DeviceType.XTERM)) {
+                     if (decPrivateModeFlag == true) {
+                         // DECTCEM
+                         if (value == true) {
+                             // Visible cursor
+                             cursorVisible = true;
+                         } else {
+                             // Invisible cursor
+                             cursorVisible = false;
+                         }
+                     }
+                 }
+                 break;
+             case 42:
+                 if ((type == DeviceType.VT220) || (type == DeviceType.XTERM)) {
+                     if (decPrivateModeFlag == true) {
+                         // DECNRCM
+                         if (value == true) {
+                             // Select national mode NRC
+                             // Not supported
+                         } else {
+                             // Select multi-national mode
+                             // Not supported
+                         }
+                     }
+                 }
+                 break;
+             case 80:
+                 if (type == DeviceType.XTERM) {
+                     if (decPrivateModeFlag == true) {
+                         if (value == true) {
+                             // Enable sixel scrolling (default).
+                             // TODO
+                         } else {
+                             // Disable sixel scrolling.
+                             // TODO
+                         }
+                     }
+                 }
+                 break;
+             case 1000:
+                 if ((type == DeviceType.XTERM)
+                     && (decPrivateModeFlag == true)
+                 ) {
+                     // Mouse: normal tracking mode
+                     if (value == true) {
+                         mouseProtocol = MouseProtocol.NORMAL;
+                     } else {
+                         mouseProtocol = MouseProtocol.OFF;
+                     }
+                 }
+                 break;
+             case 1002:
+                 if ((type == DeviceType.XTERM)
+                     && (decPrivateModeFlag == true)
+                 ) {
+                     // Mouse: normal tracking mode
+                     if (value == true) {
+                         mouseProtocol = MouseProtocol.BUTTONEVENT;
+                     } else {
+                         mouseProtocol = MouseProtocol.OFF;
+                     }
+                 }
+                 break;
+             case 1003:
+                 if ((type == DeviceType.XTERM)
+                     && (decPrivateModeFlag == true)
+                 ) {
+                     // Mouse: Any-event tracking mode
+                     if (value == true) {
+                         mouseProtocol = MouseProtocol.ANYEVENT;
+                     } else {
+                         mouseProtocol = MouseProtocol.OFF;
+                     }
+                 }
+                 break;
+             case 1005:
+                 if ((type == DeviceType.XTERM)
+                     && (decPrivateModeFlag == true)
+                 ) {
+                     // Mouse: UTF-8 coordinates
+                     if (value == true) {
+                         mouseEncoding = MouseEncoding.UTF8;
+                     } else {
+                         mouseEncoding = MouseEncoding.X10;
+                     }
+                 }
+                 break;
+             case 1006:
+                 if ((type == DeviceType.XTERM)
+                     && (decPrivateModeFlag == true)
+                 ) {
+                     // Mouse: SGR coordinates
+                     if (value == true) {
+                         mouseEncoding = MouseEncoding.SGR;
+                     } else {
+                         mouseEncoding = MouseEncoding.X10;
+                     }
+                 }
+                 break;
+             case 1070:
+                 if (type == DeviceType.XTERM) {
+                     if (decPrivateModeFlag == true) {
+                         if (value == true) {
+                             // Use private color registers for each sixel
+                             // graphic (default).
+                             sixelPalette = null;
+                         } else {
+                             // Use shared color registers for each sixel
+                             // graphic.
+                             sixelPalette = new HashMap<Integer, java.awt.Color>();
+                         }
+                     }
+                 }
+                 break;
+             default:
+                 break;
+             }
+         }
+     }
+     /**
+      * DECSC - Save cursor.
+      */
+     private void decsc() {
+         savedState.setTo(currentState);
+     }
+     /**
+      * DECRC - Restore cursor.
+      */
+     private void decrc() {
+         currentState.setTo(savedState);
+     }
+     /**
+      * IND - Index.
+      */
+     private void ind() {
+         // Move the cursor and scroll if necessary.  If at the bottom line
+         // already, a scroll up is supposed to be performed.
+         if (currentState.cursorY == scrollRegionBottom) {
+             scrollingRegionScrollUp(scrollRegionTop, scrollRegionBottom, 1);
+         }
+         cursorDown(1, true);
+     }
+     /**
+      * RI - Reverse index.
+      */
+     private void ri() {
+         // Move the cursor and scroll if necessary.  If at the top line
+         // already, a scroll down is supposed to be performed.
+         if (currentState.cursorY == scrollRegionTop) {
+             scrollingRegionScrollDown(scrollRegionTop, scrollRegionBottom, 1);
+         }
+         cursorUp(1, true);
+     }
+     /**
+      * NEL - Next line.
+      */
+     private void nel() {
+         // Move the cursor and scroll if necessary.  If at the bottom line
+         // already, a scroll up is supposed to be performed.
+         if (currentState.cursorY == scrollRegionBottom) {
+             scrollingRegionScrollUp(scrollRegionTop, scrollRegionBottom, 1);
+         }
+         cursorDown(1, true);
+         // Reset to the beginning of the next line
+         currentState.cursorX = 0;
+     }
+     /**
+      * DECKPAM - Keypad application mode.
+      */
+     private void deckpam() {
+         keypadMode = KeypadMode.Application;
+     }
+     /**
+      * DECKPNM - Keypad numeric mode.
+      */
+     private void deckpnm() {
+         keypadMode = KeypadMode.Numeric;
+     }
+     /**
+      * Move up n spaces.
+      *
+      * @param n number of spaces to move
+      * @param honorScrollRegion if true, then do nothing if the cursor is
+      * outside the scrolling region
+      */
+     private void cursorUp(final int n, final boolean honorScrollRegion) {
+         int top;
+         /*
+          * Special case: if a user moves the cursor from the right margin, we
+          * have to reset the VT100 right margin flag.
+          */
+         if (n > 0) {
+             wrapLineFlag = false;
+         }
+         for (int i = 0; i < n; i++) {
+             if (honorScrollRegion == true) {
+                 // Honor the scrolling region
+                 if ((currentState.cursorY < scrollRegionTop)
+                     || (currentState.cursorY > scrollRegionBottom)
+                 ) {
+                     // Outside region, do nothing
+                     return;
+                 }
+                 // Inside region, go up
+                 top = scrollRegionTop;
+             } else {
+                 // Non-scrolling case
+                 top = 0;
+             }
+             if (currentState.cursorY > top) {
+                 currentState.cursorY--;
+             }
+         }
+     }
+     /**
+      * Move down n spaces.
+      *
+      * @param n number of spaces to move
+      * @param honorScrollRegion if true, then do nothing if the cursor is
+      * outside the scrolling region
+      */
+     private void cursorDown(final int n, final boolean honorScrollRegion) {
+         int bottom;
+         /*
+          * Special case: if a user moves the cursor from the right margin, we
+          * have to reset the VT100 right margin flag.
+          */
+         if (n > 0) {
+             wrapLineFlag = false;
+         }
+         for (int i = 0; i < n; i++) {
+             if (honorScrollRegion == true) {
+                 // Honor the scrolling region
+                 if (currentState.cursorY > scrollRegionBottom) {
+                     // Outside region, do nothing
+                     return;
+                 }
+                 // Inside region, go down
+                 bottom = scrollRegionBottom;
+             } else {
+                 // Non-scrolling case
+                 bottom = height - 1;
+             }
+             if (currentState.cursorY < bottom) {
+                 currentState.cursorY++;
+             }
+         }
+     }
+     /**
+      * Move left n spaces.
+      *
+      * @param n number of spaces to move
+      * @param honorScrollRegion if true, then do nothing if the cursor is
+      * outside the scrolling region
+      */
+     private void cursorLeft(final int n, final boolean honorScrollRegion) {
+         /*
+          * Special case: if a user moves the cursor from the right margin, we
+          * have to reset the VT100 right margin flag.
+          */
+         if (n > 0) {
+             wrapLineFlag = false;
+         }
+         for (int i = 0; i < n; i++) {
+             if (honorScrollRegion == true) {
+                 // Honor the scrolling region
+                 if ((currentState.cursorY < scrollRegionTop)
+                     || (currentState.cursorY > scrollRegionBottom)
+                 ) {
+                     // Outside region, do nothing
+                     return;
+                 }
+             }
+             if (currentState.cursorX > 0) {
+                 currentState.cursorX--;
+             }
+         }
+     }
+     /**
+      * Move right n spaces.
+      *
+      * @param n number of spaces to move
+      * @param honorScrollRegion if true, then do nothing if the cursor is
+      * outside the scrolling region
+      */
+     private void cursorRight(final int n, final boolean honorScrollRegion) {
+         int rightMargin = this.rightMargin;
+         /*
+          * Special case: if a user moves the cursor from the right margin, we
+          * have to reset the VT100 right margin flag.
+          */
+         if (n > 0) {
+             wrapLineFlag = false;
+         }
+         if (display.get(currentState.cursorY).isDoubleWidth()) {
+             rightMargin = ((rightMargin + 1) / 2) - 1;
+         }
+         for (int i = 0; i < n; i++) {
+             if (honorScrollRegion == true) {
+                 // Honor the scrolling region
+                 if ((currentState.cursorY < scrollRegionTop)
+                     || (currentState.cursorY > scrollRegionBottom)
+                 ) {
+                     // Outside region, do nothing
+                     return;
+                 }
+             }
+             if (currentState.cursorX < rightMargin) {
+                 currentState.cursorX++;
+             }
+         }
+     }
+     /**
+      * Move cursor to (col, row) where (0, 0) is the top-left corner.
+      *
+      * @param row row to move to
+      * @param col column to move to
+      */
+     private void cursorPosition(int row, final int col) {
+         int rightMargin = this.rightMargin;
+         assert (col >= 0);
+         assert (row >= 0);
+         if (display.get(currentState.cursorY).isDoubleWidth()) {
+             rightMargin = ((rightMargin + 1) / 2) - 1;
+         }
+         // Set column number
+         currentState.cursorX = col;
+         // Sanity check, bring column back to margin.
+         if (currentState.cursorX > rightMargin) {
+             currentState.cursorX = rightMargin;
+         }
+         // Set row number
+         if (currentState.originMode == true) {
+             row += scrollRegionTop;
+         }
+         if (currentState.cursorY < row) {
+             cursorDown(row - currentState.cursorY, false);
+         } else if (currentState.cursorY > row) {
+             cursorUp(currentState.cursorY - row, false);
+         }
+         wrapLineFlag = false;
+     }
+     /**
+      * HTS - Horizontal tabulation set.
+      */
+     private void hts() {
+         for (Integer stop: tabStops) {
+             if (stop == currentState.cursorX) {
+                 // Already have a tab stop here
+                 return;
+             }
+         }
+         // Append a tab stop to the end of the array and resort them
+         tabStops.add(currentState.cursorX);
+         Collections.sort(tabStops);
+     }
+     /**
+      * DECSWL - Single-width line.
+      */
+     private void decswl() {
+         display.get(currentState.cursorY).setDoubleWidth(false);
+         display.get(currentState.cursorY).setDoubleHeight(0);
+     }
+     /**
+      * DECDWL - Double-width line.
+      */
+     private void decdwl() {
+         display.get(currentState.cursorY).setDoubleWidth(true);
+         display.get(currentState.cursorY).setDoubleHeight(0);
+     }
+     /**
+      * DECHDL - Double-height + double-width line.
+      *
+      * @param topHalf if true, this sets the row to be the top half row of a
+      * double-height row
+      */
+     private void dechdl(final boolean topHalf) {
+         display.get(currentState.cursorY).setDoubleWidth(true);
+         if (topHalf == true) {
+             display.get(currentState.cursorY).setDoubleHeight(1);
+         } else {
+             display.get(currentState.cursorY).setDoubleHeight(2);
+         }
+     }
+     /**
+      * DECALN - Screen alignment display.
+      */
+     private void decaln() {
+         Cell newCell = new Cell('E');
+         for (DisplayLine line: display) {
+             for (int i = 0; i < line.length(); i++) {
+                 line.replace(i, newCell);
+             }
+         }
+     }
+     /**
+      * DECSCL - Compatibility level.
+      */
+     private void decscl() {
+         int i = getCsiParam(0, 0);
+         int j = getCsiParam(1, 0);
+         if (i == 61) {
+             // Reset fonts
+             currentState.g0Charset = CharacterSet.US;
+             currentState.g1Charset = CharacterSet.DRAWING;
+             s8c1t = false;
+         } else if (i == 62) {
+             if ((j == 0) || (j == 2)) {
+                 s8c1t = true;
+             } else if (j == 1) {
+                 s8c1t = false;
+             }
+         }
+     }
+     /**
+      * CUD - Cursor down.
+      */
+     private void cud() {
+         cursorDown(getCsiParam(0, 1, 1, height), true);
+     }
+     /**
+      * CUF - Cursor forward.
+      */
+     private void cuf() {
+         cursorRight(getCsiParam(0, 1, 1, rightMargin + 1), true);
+     }
+     /**
+      * CUB - Cursor backward.
+      */
+     private void cub() {
+         cursorLeft(getCsiParam(0, 1, 1, currentState.cursorX + 1), true);
+     }
+     /**
+      * CUU - Cursor up.
+      */
+     private void cuu() {
+         cursorUp(getCsiParam(0, 1, 1, currentState.cursorY + 1), true);
+     }
+     /**
+      * CUP - Cursor position.
+      */
+     private void cup() {
+         cursorPosition(getCsiParam(0, 1, 1, height) - 1,
+             getCsiParam(1, 1, 1, rightMargin + 1) - 1);
+     }
+     /**
+      * CNL - Cursor down and to column 1.
+      */
+     private void cnl() {
+         cursorDown(getCsiParam(0, 1, 1, height), true);
+         // To column 0
+         cursorLeft(currentState.cursorX, true);
+     }
+     /**
+      * CPL - Cursor up and to column 1.
+      */
+     private void cpl() {
+         cursorUp(getCsiParam(0, 1, 1, currentState.cursorY + 1), true);
+         // To column 0
+         cursorLeft(currentState.cursorX, true);
+     }
+     /**
+      * CHA - Cursor to column # in current row.
+      */
+     private void cha() {
+         cursorPosition(currentState.cursorY,
+             getCsiParam(0, 1, 1, rightMargin + 1) - 1);
+     }
+     /**
+      * VPA - Cursor to row #, same column.
+      */
+     private void vpa() {
+         cursorPosition(getCsiParam(0, 1, 1, height) - 1,
+             currentState.cursorX);
+     }
+     /**
+      * ED - Erase in display.
+      */
+     private void ed() {
+         boolean honorProtected = false;
+         boolean decPrivateModeFlag = false;
+         for (int i = 0; i < collectBuffer.length(); i++) {
+             if (collectBuffer.charAt(i) == '?') {
+                 decPrivateModeFlag = true;
+                 break;
+             }
+         }
+         if (((type == DeviceType.VT220) || (type == DeviceType.XTERM))
+             && (decPrivateModeFlag == true)
+         ) {
+             honorProtected = true;
+         }
+         int i = getCsiParam(0, 0);
+         if (i == 0) {
+             // Erase from here to end of screen
+             if (currentState.cursorY < height - 1) {
+                 eraseScreen(currentState.cursorY + 1, 0, height - 1, width - 1,
+                     honorProtected);
+             }
+             eraseLine(currentState.cursorX, width - 1, honorProtected);
+         } else if (i == 1) {
+             // Erase from beginning of screen to here
+             eraseScreen(0, 0, currentState.cursorY - 1, width - 1,
+                 honorProtected);
+             eraseLine(0, currentState.cursorX, honorProtected);
+         } else if (i == 2) {
+             // Erase entire screen
+             eraseScreen(0, 0, height - 1, width - 1, honorProtected);
+         }
+     }
+     /**
+      * EL - Erase in line.
+      */
+     private void el() {
+         boolean honorProtected = false;
+         boolean decPrivateModeFlag = false;
+         for (int i = 0; i < collectBuffer.length(); i++) {
+             if (collectBuffer.charAt(i) == '?') {
+                 decPrivateModeFlag = true;
+                 break;
+             }
+         }
+         if (((type == DeviceType.VT220) || (type == DeviceType.XTERM))
+             && (decPrivateModeFlag == true)
+         ) {
+             honorProtected = true;
+         }
+         int i = getCsiParam(0, 0);
+         if (i == 0) {
+             // Erase from here to end of line
+             eraseLine(currentState.cursorX, width - 1, honorProtected);
+         } else if (i == 1) {
+             // Erase from beginning of line to here
+             eraseLine(0, currentState.cursorX, honorProtected);
+         } else if (i == 2) {
+             // Erase entire line
+             eraseLine(0, width - 1, honorProtected);
+         }
+     }
+     /**
+      * ECH - Erase # of characters in current row.
+      */
+     private void ech() {
+         int i = getCsiParam(0, 1, 1, width);
+         // Erase from here to i characters
+         eraseLine(currentState.cursorX, currentState.cursorX + i - 1, false);
+     }
+     /**
+      * IL - Insert line.
+      */
+     private void il() {
+         int i = getCsiParam(0, 1);
+         if ((currentState.cursorY >= scrollRegionTop)
+             && (currentState.cursorY <= scrollRegionBottom)
+         ) {
+             // I can get the same effect with a scroll-down
+             scrollingRegionScrollDown(currentState.cursorY,
+                 scrollRegionBottom, i);
+         }
+     }
+     /**
+      * DCH - Delete char.
+      */
+     private void dch() {
+         int n = getCsiParam(0, 1);
+         DisplayLine line = display.get(currentState.cursorY);
+         Cell blank = new Cell();
+         for (int i = 0; i < n; i++) {
+             line.delete(currentState.cursorX, blank);
+         }
+     }
+     /**
+      * ICH - Insert blank char at cursor.
+      */
+     private void ich() {
+         int n = getCsiParam(0, 1);
+         DisplayLine line = display.get(currentState.cursorY);
+         Cell blank = new Cell();
+         for (int i = 0; i < n; i++) {
+             line.insert(currentState.cursorX, blank);
+         }
+     }
+     /**
+      * DL - Delete line.
+      */
+     private void dl() {
+         int i = getCsiParam(0, 1);
+         if ((currentState.cursorY >= scrollRegionTop)
+             && (currentState.cursorY <= scrollRegionBottom)) {
+             // I can get the same effect with a scroll-down
+             scrollingRegionScrollUp(currentState.cursorY,
+                 scrollRegionBottom, i);
+         }
+     }
+     /**
+      * HVP - Horizontal and vertical position.
+      */
+     private void hvp() {
+         cup();
+     }
+     /**
+      * REP - Repeat character.
+      */
+     private void rep() {
+         int n = getCsiParam(0, 1);
+         for (int i = 0; i < n; i++) {
+             printCharacter(repCh);
+         }
+     }
+     /**
+      * SU - Scroll up.
+      */
+     private void su() {
+         scrollingRegionScrollUp(scrollRegionTop, scrollRegionBottom,
+             getCsiParam(0, 1, 1, height));
+     }
+     /**
+      * SD - Scroll down.
+      */
+     private void sd() {
+         scrollingRegionScrollDown(scrollRegionTop, scrollRegionBottom,
+             getCsiParam(0, 1, 1, height));
+     }
+     /**
+      * CBT - Go back X tab stops.
+      */
+     private void cbt() {
+         int tabsToMove = getCsiParam(0, 1);
+         int tabI;
+         for (int i = 0; i < tabsToMove; i++) {
+             int j = currentState.cursorX;
+             for (tabI = 0; tabI < tabStops.size(); tabI++) {
+                 if (tabStops.get(tabI) >= currentState.cursorX) {
+                     break;
+                 }
+             }
+             tabI--;
+             if (tabI <= 0) {
+                 j = 0;
+             } else {
+                 j = tabStops.get(tabI);
+             }
+             cursorPosition(currentState.cursorY, j);
+         }
+     }
+     /**
+      * CHT - Advance X tab stops.
+      */
+     private void cht() {
+         int n = getCsiParam(0, 1);
+         for (int i = 0; i < n; i++) {
+             advanceToNextTabStop();
+         }
+     }
+     /**
+      * SGR - Select graphics rendition.
+      */
+     private void sgr() {
+         if (csiParams.size() == 0) {
+             currentState.attr.reset();
+             return;
+         }
+         int sgrColorMode = -1;
+         boolean idx88Color = false;
+         boolean rgbColor = false;
+         int rgbRed = -1;
+         int rgbGreen = -1;
+         for (Integer i: csiParams) {
+             if ((sgrColorMode == 38) || (sgrColorMode == 48)) {
+                 assert (type == DeviceType.XTERM);
+                 if (idx88Color) {
+                     /*
+                      * Indexed color mode, we now have the index number.
+                      */
+                     if (sgrColorMode == 38) {
+                         currentState.attr.setForeColorRGB(get88Color(i));
+                     } else {
+                         assert (sgrColorMode == 48);
+                         currentState.attr.setBackColorRGB(get88Color(i));
+                     }
+                     sgrColorMode = -1;
+                     idx88Color = false;
+                     continue;
+                 }
+                 if (rgbColor) {
+                     /*
+                      * RGB color mode, we are collecting tokens.
+                      */
+                     if (rgbRed == -1) {
+                         rgbRed = i & 0xFF;
+                     } else if (rgbGreen == -1) {
+                         rgbGreen = i & 0xFF;
+                     } else {
+                         int rgb = rgbRed << 16;
+                         rgb |= rgbGreen << 8;
+                         rgb |= i & 0xFF;
+                         // System.err.printf("RGB: %08x\n", rgb);
+                         if (sgrColorMode == 38) {
+                             currentState.attr.setForeColorRGB(rgb);
+                         } else {
+                             assert (sgrColorMode == 48);
+                             currentState.attr.setBackColorRGB(rgb);
+                         }
+                         rgbRed = -1;
+                         rgbGreen = -1;
+                         sgrColorMode = -1;
+                         rgbColor = false;
+                     }
+                     continue;
+                 }
+                 switch (i) {
+                 case 2:
+                     /*
+                      * RGB color mode.
+                      */
+                     rgbColor = true;
+                     break;
+                 case 5:
+                     /*
+                      * Indexed color mode.
+                      */
+                     idx88Color = true;
+                     break;
+                 default:
+                     /*
+                      * This is neither indexed nor RGB color.  Bail out.
+                      */
+                     return;
+                 }
+             } // if ((sgrColorMode == 38) || (sgrColorMode == 48))
+             switch (i) {
+             case 0:
+                 // Normal
+                 currentState.attr.reset();
+                 break;
+             case 1:
+                 // Bold
+                 currentState.attr.setBold(true);
+                 break;
+             case 4:
+                 // Underline
+                 currentState.attr.setUnderline(true);
+                 break;
+             case 5:
+                 // Blink
+                 currentState.attr.setBlink(true);
+                 break;
+             case 7:
+                 // Reverse
+                 currentState.attr.setReverse(true);
+                 break;
+             default:
+                 break;
+             }
+             if (type == DeviceType.XTERM) {
+                 switch (i) {
+                 case 8:
+                     // Invisible
+                     // TODO
+                     break;
+                 case 90:
+                     // Set black foreground
+                     currentState.attr.setForeColorRGB(get88Color(8));
+                     break;
+                 case 91:
+                     // Set red foreground
+                     currentState.attr.setForeColorRGB(get88Color(9));
+                     break;
+                 case 92:
+                     // Set green foreground
+                     currentState.attr.setForeColorRGB(get88Color(10));
+                     break;
+                 case 93:
+                     // Set yellow foreground
+                     currentState.attr.setForeColorRGB(get88Color(11));
+                     break;
+                 case 94:
+                     // Set blue foreground
+                     currentState.attr.setForeColorRGB(get88Color(12));
+                     break;
+                 case 95:
+                     // Set magenta foreground
+                     currentState.attr.setForeColorRGB(get88Color(13));
+                     break;
+                 case 96:
+                     // Set cyan foreground
+                     currentState.attr.setForeColorRGB(get88Color(14));
+                     break;
+                 case 97:
+                     // Set white foreground
+                     currentState.attr.setForeColorRGB(get88Color(15));
+                     break;
+                 case 100:
+                     // Set black background
+                     currentState.attr.setBackColorRGB(get88Color(8));
+                     break;
+                 case 101:
+                     // Set red background
+                     currentState.attr.setBackColorRGB(get88Color(9));
+                     break;
+                 case 102:
+                     // Set green background
+                     currentState.attr.setBackColorRGB(get88Color(10));
+                     break;
+                 case 103:
+                     // Set yellow background
+                     currentState.attr.setBackColorRGB(get88Color(11));
+                     break;
+                 case 104:
+                     // Set blue background
+                     currentState.attr.setBackColorRGB(get88Color(12));
+                     break;
+                 case 105:
+                     // Set magenta background
+                     currentState.attr.setBackColorRGB(get88Color(13));
+                     break;
+                 case 106:
+                     // Set cyan background
+                     currentState.attr.setBackColorRGB(get88Color(14));
+                     break;
+                 case 107:
+                     // Set white background
+                     currentState.attr.setBackColorRGB(get88Color(15));
+                     break;
+                 default:
+                     break;
+                 }
+             }
+             if ((type == DeviceType.VT220)
+                 || (type == DeviceType.XTERM)) {
+                 switch (i) {
+                 case 22:
+                     // Normal intensity
+                     currentState.attr.setBold(false);
+                     break;
+                 case 24:
+                     // No underline
+                     currentState.attr.setUnderline(false);
+                     break;
+                 case 25:
+                     // No blink
+                     currentState.attr.setBlink(false);
+                     break;
+                 case 27:
+                     // Un-reverse
+                     currentState.attr.setReverse(false);
+                     break;
+                 default:
+                     break;
+                 }
+             }
+             // A true VT100/102/220 does not support color, however everyone
+             // is used to their terminal emulator supporting color so we will
+             // unconditionally support color for all DeviceType's.
+             switch (i) {
+             case 30:
+                 // Set black foreground
+                 currentState.attr.setForeColor(Color.BLACK);
+                 currentState.attr.setForeColorRGB(-1);
+                 break;
+             case 31:
+                 // Set red foreground
+                 currentState.attr.setForeColor(Color.RED);
+                 currentState.attr.setForeColorRGB(-1);
+                 break;
+             case 32:
+                 // Set green foreground
+                 currentState.attr.setForeColor(Color.GREEN);
+                 currentState.attr.setForeColorRGB(-1);
+                 break;
+             case 33:
+                 // Set yellow foreground
+                 currentState.attr.setForeColor(Color.YELLOW);
+                 currentState.attr.setForeColorRGB(-1);
+                 break;
+             case 34:
+                 // Set blue foreground
+                 currentState.attr.setForeColor(Color.BLUE);
+                 currentState.attr.setForeColorRGB(-1);
+                 break;
+             case 35:
+                 // Set magenta foreground
+                 currentState.attr.setForeColor(Color.MAGENTA);
+                 currentState.attr.setForeColorRGB(-1);
+                 break;
+             case 36:
+                 // Set cyan foreground
+                 currentState.attr.setForeColor(Color.CYAN);
+                 currentState.attr.setForeColorRGB(-1);
+                 break;
+             case 37:
+                 // Set white foreground
+                 currentState.attr.setForeColor(Color.WHITE);
+                 currentState.attr.setForeColorRGB(-1);
+                 break;
+             case 38:
+                 if (type == DeviceType.XTERM) {
+                     /*
+                      * Xterm supports T.416 / ISO-8613-3 codes to select
+                      * either an indexed color or an RGB value.  (It also
+                      * permits these ISO-8613-3 SGR sequences to be separated
+                      * by colons rather than semicolons.)
+                      *
+                      * We will support only the following:
+                      *
+                      * 1. Indexed color mode (88- or 256-color modes).
+                      *
+                      * 2. Direct RGB.
+                      *
+                      * These cover most of the use cases in the real world.
+                      *
+                      * HOWEVER, note that this is an awful broken "standard",
+                      * with no way to do it "right".  See
+                      * http://invisible-island.net/ncurses/ncurses.faq.html#xterm_16MegaColors
+                      * for a detailed discussion of the current state of RGB
+                      * in various terminals, the point of which is that none
+                      * of them really do the same thing despite all appearing
+                      * to be "xterm".
+                      *
+                      * Also see
+                      * https://bugs.kde.org/show_bug.cgi?id=107487#c3 .
+                      * where it is assumed that supporting just the "indexed
+                      * mode" of these sequences (which could align easily
+                      * with existing SGR colors) is assumed to mean full
+                      * support of 24-bit RGB.  So it is all or nothing.
+                      *
+                      * Finally, these sequences break the assumptions of
+                      * standard ECMA-48 style parsers as pointed out at
+                      * https://bugs.kde.org/show_bug.cgi?id=107487#c11 .
+                      * Therefore in order to keep a clean display, we cannot
+                      * parse anything else in this sequence.
+                      */
+                     sgrColorMode = 38;
+                     continue;
+                 } else {
+                     // Underscore on, default foreground color
+                     currentState.attr.setUnderline(true);
+                     currentState.attr.setForeColor(Color.WHITE);
+                 }
+                 break;
+             case 39:
+                 // Underscore off, default foreground color
+                 currentState.attr.setUnderline(false);
+                 currentState.attr.setForeColor(Color.WHITE);
+                 currentState.attr.setForeColorRGB(-1);
+                 break;
+             case 40:
+                 // Set black background
+                 currentState.attr.setBackColor(Color.BLACK);
+                 currentState.attr.setBackColorRGB(-1);
+                 break;
+             case 41:
+                 // Set red background
+                 currentState.attr.setBackColor(Color.RED);
+                 currentState.attr.setBackColorRGB(-1);
+                 break;
+             case 42:
+                 // Set green background
+                 currentState.attr.setBackColor(Color.GREEN);
+                 currentState.attr.setBackColorRGB(-1);
+                 break;
+             case 43:
+                 // Set yellow background
+                 currentState.attr.setBackColor(Color.YELLOW);
+                 currentState.attr.setBackColorRGB(-1);
+                 break;
+             case 44:
+                 // Set blue background
+                 currentState.attr.setBackColor(Color.BLUE);
+                 currentState.attr.setBackColorRGB(-1);
+                 break;
+             case 45:
+                 // Set magenta background
+                 currentState.attr.setBackColor(Color.MAGENTA);
+                 currentState.attr.setBackColorRGB(-1);
+                 break;
+             case 46:
+                 // Set cyan background
+                 currentState.attr.setBackColor(Color.CYAN);
+                 currentState.attr.setBackColorRGB(-1);
+                 break;
+             case 47:
+                 // Set white background
+                 currentState.attr.setBackColor(Color.WHITE);
+                 currentState.attr.setBackColorRGB(-1);
+                 break;
+             case 48:
+                 if (type == DeviceType.XTERM) {
+                     /*
+                      * Xterm supports T.416 / ISO-8613-3 codes to select
+                      * either an indexed color or an RGB value.  (It also
+                      * permits these ISO-8613-3 SGR sequences to be separated
+                      * by colons rather than semicolons.)
+                      *
+                      * We will support only the following:
+                      *
+                      * 1. Indexed color mode (88- or 256-color modes).
+                      *
+                      * 2. Direct RGB.
+                      *
+                      * These cover most of the use cases in the real world.
+                      *
+                      * HOWEVER, note that this is an awful broken "standard",
+                      * with no way to do it "right".  See
+                      * http://invisible-island.net/ncurses/ncurses.faq.html#xterm_16MegaColors
+                      * for a detailed discussion of the current state of RGB
+                      * in various terminals, the point of which is that none
+                      * of them really do the same thing despite all appearing
+                      * to be "xterm".
+                      *
+                      * Also see
+                      * https://bugs.kde.org/show_bug.cgi?id=107487#c3 .
+                      * where it is assumed that supporting just the "indexed
+                      * mode" of these sequences (which could align easily
+                      * with existing SGR colors) is assumed to mean full
+                      * support of 24-bit RGB.  So it is all or nothing.
+                      *
+                      * Finally, these sequences break the assumptions of
+                      * standard ECMA-48 style parsers as pointed out at
+                      * https://bugs.kde.org/show_bug.cgi?id=107487#c11 .
+                      * Therefore in order to keep a clean display, we cannot
+                      * parse anything else in this sequence.
+                      */
+                     sgrColorMode = 48;
+                     continue;
+                 }
+                 break;
+             case 49:
+                 // Default background
+                 currentState.attr.setBackColor(Color.BLACK);
+                 currentState.attr.setBackColorRGB(-1);
+                 break;
+             default:
+                 break;
+             }
+         }
+     }
+     /**
+      * DA - Device attributes.
+      */
+     private void da() {
+         int extendedFlag = 0;
+         int i = 0;
+         if (collectBuffer.length() > 0) {
+             String args = collectBuffer.substring(1);
+             if (collectBuffer.charAt(0) == '>') {
+                 extendedFlag = 1;
+                 if (collectBuffer.length() >= 2) {
+                     i = Integer.parseInt(args);
+                 }
+             } else if (collectBuffer.charAt(0) == '=') {
+                 extendedFlag = 2;
+                 if (collectBuffer.length() >= 2) {
+                     i = Integer.parseInt(args);
+                 }
+             } else {
+                 // Unknown code, bail out
+                 return;
+             }
+         }
+         if ((i != 0) && (i != 1)) {
+             return;
+         }
+         if ((extendedFlag == 0) && (i == 0)) {
+             // Send string directly to remote side
+             writeRemote(deviceTypeResponse());
+             return;
+         }
+         if ((type == DeviceType.VT220) || (type == DeviceType.XTERM)) {
+             if ((extendedFlag == 1) && (i == 0)) {
+                 /*
+                  * Request "What type of terminal are you, what is your
+                  * firmware version, and what hardware options do you have
+                  * installed?"
+                  *
+                  * Respond: "I am a VT220 (identification code of 1), my
+                  * firmware version is _____ (Pv), and I have _____ Po
+                  * options installed."
+                  *
+                  * (Same as xterm)
+                  *
+                  */
+                 if (s8c1t == true) {
+                     writeRemote("\u009b>1;10;0c");
+                 } else {
+                     writeRemote("\033[>1;10;0c");
+                 }
+             }
+         }
+         // VT420 and up
+         if ((extendedFlag == 2) && (i == 0)) {
+             /*
+              * Request "What is your unit ID?"
+              *
+              * Respond: "I was manufactured at site 00 and have a unique ID
+              * number of 123."
+              *
+              */
+             writeRemote("\033P!|00010203\033\\");
+         }
+     }
+     /**
+      * DECSTBM - Set top and bottom margins.
+      */
+     private void decstbm() {
+         boolean decPrivateModeFlag = false;
+         for (int i = 0; i < collectBuffer.length(); i++) {
+             if (collectBuffer.charAt(i) == '?') {
+                 decPrivateModeFlag = true;
+                 break;
+             }
+         }
+         if (decPrivateModeFlag) {
+             // This could be restore DEC private mode values.
+             // Ignore it.
+         } else {
+             // DECSTBM
+             int top = getCsiParam(0, 1, 1, height) - 1;
+             int bottom = getCsiParam(1, height, 1, height) - 1;
+             if (top > bottom) {
+                 top = bottom;
+             }
+             scrollRegionTop = top;
+             scrollRegionBottom = bottom;
+             // Home cursor
+             cursorPosition(0, 0);
+         }
+     }
+     /**
+      * DECREQTPARM - Request terminal parameters.
+      */
+     private void decreqtparm() {
+         int i = getCsiParam(0, 0);
+         if ((i != 0) && (i != 1)) {
+                 return;
+         }
+         String str = "";
+         /*
+          * Request terminal parameters.
+          *
+          * Respond with:
+          *
+          *     Parity NONE, 8 bits, xmitspeed 38400, recvspeed 38400.
+          *     (CLoCk MULtiplier = 1, STP option flags = 0)
+          *
+          * (Same as xterm)
+          */
+         if (((type == DeviceType.VT220) || (type == DeviceType.XTERM))
+             && (s8c1t == true)
+         ) {
+             str = String.format("\u009b%d;1;1;128;128;1;0x", i + 2);
+         } else {
+             str = String.format("\033[%d;1;1;128;128;1;0x", i + 2);
+         }
+         writeRemote(str);
+     }
+     /**
+      * DECSCA - Select Character Attributes.
+      */
+     private void decsca() {
+         int i = getCsiParam(0, 0);
+         if ((i == 0) || (i == 2)) {
+             // Protect mode OFF
+             currentState.attr.setProtect(false);
+         }
+         if (i == 1) {
+             // Protect mode ON
+             currentState.attr.setProtect(true);
+         }
+     }
+     /**
+      * DECSTR - Soft Terminal Reset.
+      */
+     private void decstr() {
+         // Do exactly like RIS - Reset to initial state
+         reset();
+         // Do I clear screen too? I think so...
+         eraseScreen(0, 0, height - 1, width - 1, false);
+         cursorPosition(0, 0);
+     }
+     /**
+      * DSR - Device status report.
+      */
+     private void dsr() {
+         boolean decPrivateModeFlag = false;
+         int row = currentState.cursorY;
+         for (int i = 0; i < collectBuffer.length(); i++) {
+             if (collectBuffer.charAt(i) == '?') {
+                 decPrivateModeFlag = true;
+                 break;
+             }
+         }
+         int i = getCsiParam(0, 0);
+         switch (i) {
+         case 5:
+             // Request status report. Respond with "OK, no malfunction."
+             // Send string directly to remote side
+             if (((type == DeviceType.VT220) || (type == DeviceType.XTERM))
+                 && (s8c1t == true)
+             ) {
+                 writeRemote("\u009b0n");
+             } else {
+                 writeRemote("\033[0n");
+             }
+             break;
+         case 6:
+             // Request cursor position.  Respond with current position.
+             if (currentState.originMode == true) {
+                 row -= scrollRegionTop;
+             }
+             String str = "";
+             if (((type == DeviceType.VT220) || (type == DeviceType.XTERM))
+                 && (s8c1t == true)
+             ) {
+                 str = String.format("\u009b%d;%dR", row + 1,
+                     currentState.cursorX + 1);
+             } else {
+                 str = String.format("\033[%d;%dR", row + 1,
+                     currentState.cursorX + 1);
+             }
+             // Send string directly to remote side
+             writeRemote(str);
+             break;
+         case 15:
+             if (decPrivateModeFlag == true) {
+                 // Request printer status report.  Respond with "Printer not
+                 // connected."
+                 if (((type == DeviceType.VT220) || (type == DeviceType.XTERM))
+                     && (s8c1t == true)) {
+                     writeRemote("\u009b?13n");
+                 } else {
+                     writeRemote("\033[?13n");
+                 }
+             }
+             break;
+         case 25:
+             if (((type == DeviceType.VT220) || (type == DeviceType.XTERM))
+                 && (decPrivateModeFlag == true)
+             ) {
+                 // Request user-defined keys are locked or unlocked.  Respond
+                 // with "User-defined keys are locked."
+                 if (s8c1t == true) {
+                     writeRemote("\u009b?21n");
+                 } else {
+                     writeRemote("\033[?21n");
+                 }
+             }
+             break;
+         case 26:
+             if (((type == DeviceType.VT220) || (type == DeviceType.XTERM))
+                 && (decPrivateModeFlag == true)
+             ) {
+                 // Request keyboard language.  Respond with "Keyboard
+                 // language is North American."
+                 if (s8c1t == true) {
+                     writeRemote("\u009b?27;1n");
+                 } else {
+                     writeRemote("\033[?27;1n");
+                 }
+             }
+             break;
+         default:
+             // Some other option, ignore
+             break;
+         }
+     }
+     /**
+      * TBC - Tabulation clear.
+      */
+     private void tbc() {
+         int i = getCsiParam(0, 0);
+         if (i == 0) {
+             List<Integer> newStops = new ArrayList<Integer>();
+             for (Integer stop: tabStops) {
+                 if (stop == currentState.cursorX) {
+                     continue;
+                 }
+                 newStops.add(stop);
+             }
+             tabStops = newStops;
+         }
+         if (i == 3) {
+             tabStops.clear();
+         }
+     }
+     /**
+      * Erase the characters in the current line from the start column to the
+      * end column, inclusive.
+      *
+      * @param start starting column to erase (between 0 and width - 1)
+      * @param end ending column to erase (between 0 and width - 1)
+      * @param honorProtected if true, do not erase characters with the
+      * protected attribute set
+      */
+     private void eraseLine(int start, int end, final boolean honorProtected) {
+         if (start > end) {
+             return;
+         }
+         if (end > width - 1) {
+             end = width - 1;
+         }
+         if (start < 0) {
+             start = 0;
+         }
+         for (int i = start; i <= end; i++) {
+             DisplayLine line = display.get(currentState.cursorY);
+             if ((!honorProtected)
+                 || ((honorProtected) && (!line.charAt(i).isProtect()))) {
+                 switch (type) {
+                 case VT100:
+                 case VT102:
+                 case VT220:
+                     /*
+                      * From the VT102 manual:
+                      *
+                      * Erasing a character also erases any character
+                      * attribute of the character.
+                      */
+                     line.setBlank(i);
+                     break;
+                 case XTERM:
+                     /*
+                      * Erase with the current color a.k.a. back-color erase
+                      * (bce).
+                      */
+                     line.setChar(i, ' ');
+                     line.setAttr(i, currentState.attr);
+                     break;
+                 }
+             }
+         }
+     }
+     /**
+      * Erase a rectangular section of the screen, inclusive.  end column,
+      * inclusive.
+      *
+      * @param startRow starting row to erase (between 0 and height - 1)
+      * @param startCol starting column to erase (between 0 and width - 1)
+      * @param endRow ending row to erase (between 0 and height - 1)
+      * @param endCol ending column to erase (between 0 and width - 1)
+      * @param honorProtected if true, do not erase characters with the
+      * protected attribute set
+      */
+     private void eraseScreen(final int startRow, final int startCol,
+         final int endRow, final int endCol, final boolean honorProtected) {
+         int oldCursorY;
+         if ((startRow < 0)
+             || (startCol < 0)
+             || (endRow < 0)
+             || (endCol < 0)
+             || (endRow < startRow)
+             || (endCol < startCol)
+         ) {
+             return;
+         }
+         oldCursorY = currentState.cursorY;
+         for (int i = startRow; i <= endRow; i++) {
+             currentState.cursorY = i;
+             eraseLine(startCol, endCol, honorProtected);
+             // Erase display clears the double attributes
+             display.get(i).setDoubleWidth(false);
+             display.get(i).setDoubleHeight(0);
+         }
+         currentState.cursorY = oldCursorY;
+     }
+     /**
+      * VT220 printer functions.  All of these are parsed, but won't do
+      * anything.
+      */
+     private void printerFunctions() {
+         boolean decPrivateModeFlag = false;
+         for (int i = 0; i < collectBuffer.length(); i++) {
+             if (collectBuffer.charAt(i) == '?') {
+                 decPrivateModeFlag = true;
+                 break;
+             }
+         }
+         int i = getCsiParam(0, 0);
+         switch (i) {
+         case 0:
+             if (decPrivateModeFlag == false) {
+                 // Print screen
+             }
+             break;
+         case 1:
+             if (decPrivateModeFlag == true) {
+                 // Print cursor line
+             }
+             break;
+         case 4:
+             if (decPrivateModeFlag == true) {
+                 // Auto print mode OFF
+             } else {
+                 // Printer controller OFF
+                 // Characters re-appear on the screen
+                 printerControllerMode = false;
+             }
+             break;
+         case 5:
+             if (decPrivateModeFlag == true) {
+                 // Auto print mode
+             } else {
+                 // Printer controller
+                 // Characters get sucked into oblivion
+                 printerControllerMode = true;
+             }
+             break;
+         default:
+             break;
+         }
+     }
+     /**
+      * Handle the SCAN_OSC_STRING state.  Handle this in VT100 because lots
+      * of remote systems will send an XTerm title sequence even if TERM isn't
+      * xterm.
+      *
+      * @param xtermChar the character received from the remote side
+      */
+     private void oscPut(final char xtermChar) {
+         // System.err.println("oscPut: " + xtermChar);
+         // Collect first
+         collectBuffer.append(xtermChar);
+         // Xterm cases...
+         if ((xtermChar == 0x07)
+             || (collectBuffer.toString().endsWith("\033\\"))
+         ) {
+             String args = null;
+             if (xtermChar == 0x07) {
+                 args = collectBuffer.substring(0, collectBuffer.length() - 1);
+             } else {
+                 args = collectBuffer.substring(0, collectBuffer.length() - 2);
+             }
+             String [] p = args.split(";");
+             if (p.length > 0) {
+                 if ((p[0].equals("0")) || (p[0].equals("2"))) {
+                     if (p.length > 1) {
+                         // Screen title
+                         screenTitle = p[1];
+                     }
+                 }
+                 if (p[0].equals("4")) {
+                     for (int i = 1; i + 1 < p.length; i += 2) {
+                         // Set a color index value
+                         try {
+                             set88Color(Integer.parseInt(p[i]), p[i + 1]);
+                         } catch (NumberFormatException e) {
+                             // SQUASH
+                         }
+                     }
+                 }
+                 if (p[0].equals("10")) {
+                     if (p[1].equals("?")) {
+                         // Respond with foreground color.
+                         java.awt.Color color = jexer.backend.SwingTerminal.attrToForegroundColor(currentState.attr);
+                         writeRemote(String.format(
+                             "\033]10;rgb:%04x/%04x/%04x\033\\",
+                                 color.getRed() << 8,
+                                 color.getGreen() << 8,
+                                 color.getBlue() << 8));
+                     }
+                 }
+                 if (p[0].equals("11")) {
+                     if (p[1].equals("?")) {
+                         // Respond with background color.
+                         java.awt.Color color = jexer.backend.SwingTerminal.attrToBackgroundColor(currentState.attr);
+                         writeRemote(String.format(
+                             "\033]11;rgb:%04x/%04x/%04x\033\\",
+                                 color.getRed() << 8,
+                                 color.getGreen() << 8,
+                                 color.getBlue() << 8));
+                     }
+                 }
+                 if (p[0].equals("444") && (p.length == 5)) {
+                     // Jexer image
+                     parseJexerImage(p[1], p[2], p[3], p[4]);
+                 }
+             }
+             // Go to SCAN_GROUND state
+             toGround();
+             return;
+         }
+     }
+     /**
+      * Handle the SCAN_SOSPMAPC_STRING state.  This is currently only used by
+      * Jexer ECMA48Terminal to talk to ECMA48.
+      *
+      * @param pmChar the character received from the remote side
+      */
+     private void pmPut(final char pmChar) {
+         // System.err.println("pmPut: " + pmChar);
+         // Collect first
+         collectBuffer.append(pmChar);
+         // Xterm cases...
+         if (collectBuffer.toString().endsWith("\033\\")) {
+             String arg = null;
+             arg = collectBuffer.substring(0, collectBuffer.length() - 2);
+             // System.err.println("arg: '" + arg + "'");
+             if (arg.equals("hideMousePointer")) {
+                 hideMousePointer = true;
+             }
+             if (arg.equals("showMousePointer")) {
+                 hideMousePointer = false;
+             }
+             // Go to SCAN_GROUND state
+             toGround();
+             return;
+         }
+     }
+     /**
+      * Perform xterm window operations.
+      */
+     private void xtermWindowOps() {
+         boolean xtermPrivateModeFlag = false;
+         for (int i = 0; i < collectBuffer.length(); i++) {
+             if (collectBuffer.charAt(i) == '?') {
+                 xtermPrivateModeFlag = true;
+                 break;
+             }
+         }
+         int i = getCsiParam(0, 0);
+         if (!xtermPrivateModeFlag) {
+             switch (i) {
+             case 14:
+                 // Report xterm text area size in pixels as CSI 4 ; height ;
+                 // width t
+                 writeRemote(String.format("\033[4;%d;%dt", textHeight * height,
+                         textWidth * width));
+                 break;
+             case 16:
+                 // Report character size in pixels as CSI 6 ; height ; width
+                 // t
+                 writeRemote(String.format("\033[6;%d;%dt", textHeight,
+                         textWidth));
+                 break;
+             case 18:
+                 // Report the text are size in characters as CSI 8 ; height ;
+                 // width t
+                 writeRemote(String.format("\033[8;%d;%dt", height, width));
+                 break;
+             default:
+                 break;
+             }
+         }
+     }
+     /**
+      * Respond to xterm sixel query.
+      */
+     private void xtermSixelQuery() {
+         int item = getCsiParam(0, 0);
+         int action = getCsiParam(1, 0);
+         int value = getCsiParam(2, 0);
+         switch (item) {
+         case 1:
+             if (action == 1) {
+                 // Report number of color registers.
+                 writeRemote(String.format("\033[?%d;%d;%dS", item, 0, 1024));
+                 return;
+             }
+             break;
+         default:
+             break;
+         }
+         // We will not support this option.
+         writeRemote(String.format("\033[?%d;%dS", item, action));
+     }
+     /**
+      * Run this input character through the ECMA48 state machine.
+      *
+      * @param ch character from the remote side
+      */
+     private void consume(int ch) {
+         // DEBUG
+         // System.err.printf("%c STATE = %s\n", ch, scanState);
+         // Special case for VT10x: 7-bit characters only
+         if ((type == DeviceType.VT100) || (type == DeviceType.VT102)) {
+             ch = (ch & 0x7F);
+         }
+         // Special "anywhere" states
+         // 18, 1A                     --> execute, then switch to SCAN_GROUND
+         if ((ch == 0x18) || (ch == 0x1A)) {
+             // CAN and SUB abort escape sequences
+             toGround();
+             return;
+         }
+         // 80-8F, 91-97, 99, 9A, 9C   --> execute, then switch to SCAN_GROUND
+         // 0x1B == ESCAPE
+         if (ch == 0x1B) {
+             if ((type == DeviceType.XTERM)
+                 && ((scanState == ScanState.OSC_STRING)
+                     || (scanState == ScanState.DCS_SIXEL)
+                     || (scanState == ScanState.SOSPMAPC_STRING))
+             ) {
+                 // Xterm can pass ESCAPE to its OSC sequence.
+                 // Xterm can pass ESCAPE to its DCS sequence.
+                 // Jexer can pass ESCAPE to its PM sequence.
+             } else if ((scanState != ScanState.DCS_ENTRY)
+                 && (scanState != ScanState.DCS_INTERMEDIATE)
+                 && (scanState != ScanState.DCS_IGNORE)
+                 && (scanState != ScanState.DCS_PARAM)
+                 && (scanState != ScanState.DCS_PASSTHROUGH)
+             ) {
+                 scanState = ScanState.ESCAPE;
+                 return;
+             }
+         }
+         // 0x9B == CSI 8-bit sequence
+         if (ch == 0x9B) {
+             scanState = ScanState.CSI_ENTRY;
+             return;
+         }
+         // 0x9D goes to ScanState.OSC_STRING
+         if (ch == 0x9D) {
+             scanState = ScanState.OSC_STRING;
+             return;
+         }
+         // 0x90 goes to DCS_ENTRY
+         if (ch == 0x90) {
+             scanState = ScanState.DCS_ENTRY;
+             return;
+         }
+         // 0x98, 0x9E, and 0x9F go to SOSPMAPC_STRING
+         if ((ch == 0x98) || (ch == 0x9E) || (ch == 0x9F)) {
+             scanState = ScanState.SOSPMAPC_STRING;
+             return;
+         }
+         // 0x7F (DEL) is always discarded
+         if (ch == 0x7F) {
+             return;
+         }
+         switch (scanState) {
+         case GROUND:
+             // 00-17, 19, 1C-1F --> execute
+             // 80-8F, 91-9A, 9C --> execute
+             if ((ch <= 0x1F) || ((ch >= 0x80) && (ch <= 0x9F))) {
+                 handleControlChar((char) ch);
+             }
+             // 20-7F            --> print
+             if (((ch >= 0x20) && (ch <= 0x7F))
+                 || (ch >= 0xA0)
+             ) {
+                 // VT220 printer --> trash bin
+                 if (((type == DeviceType.VT220)
+                         || (type == DeviceType.XTERM))
+                     && (printerControllerMode == true)
+                 ) {
+                     return;
+                 }
+                 // Hang onto this character
+                 repCh = mapCharacter(ch);
+                 // Print this character
+                 printCharacter(repCh);
+             }
+             return;
+         case ESCAPE:
+             // 00-17, 19, 1C-1F --> execute
+             if (ch <= 0x1F) {
+                 handleControlChar((char) ch);
+                 return;
+             }
+             // 20-2F            --> collect, then switch to ESCAPE_INTERMEDIATE
+             if ((ch >= 0x20) && (ch <= 0x2F)) {
+                 collect((char) ch);
+                 scanState = ScanState.ESCAPE_INTERMEDIATE;
+                 return;
+             }
+             // 30-4F, 51-57, 59, 5A, 5C, 60-7E --> dispatch, then switch to GROUND
+             if ((ch >= 0x30) && (ch <= 0x4F)) {
+                 switch (ch) {
+                 case '0':
+                 case '1':
+                 case '2':
+                 case '3':
+                 case '4':
+                 case '5':
+                 case '6':
+                     break;
+                 case '7':
+                     // DECSC - Save cursor
+                     // Note this code overlaps both ANSI and VT52 mode
+                     decsc();
+                     break;
+                 case '8':
+                     // DECRC - Restore cursor
+                     // Note this code overlaps both ANSI and VT52 mode
+                     decrc();
+                     break;
+                 case '9':
+                 case ':':
+                 case ';':
+                     break;
+                 case '<':
+                     if (vt52Mode == true) {
+                         // DECANM - Enter ANSI mode
+                         vt52Mode = false;
+                         arrowKeyMode = ArrowKeyMode.VT100;
+                         /*
+                          * From the VT102 docs: "You use ANSI mode to select
+                          * most terminal features; the terminal uses the same
+                          * features when it switches to VT52 mode. You
+                          * cannot, however, change most of these features in
+                          * VT52 mode."
+                          *
+                          * In other words, do not reset any other attributes
+                          * when switching between VT52 submode and ANSI.
+                          */
+                         // Reset fonts
+                         currentState.g0Charset = CharacterSet.US;
+                         currentState.g1Charset = CharacterSet.DRAWING;
+                         s8c1t = false;
+                         singleshift = Singleshift.NONE;
+                         currentState.glLockshift = LockshiftMode.NONE;
+                         currentState.grLockshift = LockshiftMode.NONE;
+                     }
+                     break;
+                 case '=':
+                     // DECKPAM - Keypad application mode
+                     // Note this code overlaps both ANSI and VT52 mode
+                     deckpam();
+                     break;
+                 case '>':
+                     // DECKPNM - Keypad numeric mode
+                     // Note this code overlaps both ANSI and VT52 mode
+                     deckpnm();
+                     break;
+                 case '?':
+                 case '@':
+                     break;
+                 case 'A':
+                     if (vt52Mode == true) {
+                         // Cursor up, and stop at the top without scrolling
+                         cursorUp(1, false);
+                     }
+                     break;
+                 case 'B':
+                     if (vt52Mode == true) {
+                         // Cursor down, and stop at the bottom without scrolling
+                         cursorDown(1, false);
+                     }
+                     break;
+                 case 'C':
+                     if (vt52Mode == true) {
+                         // Cursor right, and stop at the right without scrolling
+                         cursorRight(1, false);
+                     }
+                     break;
+                 case 'D':
+                     if (vt52Mode == true) {
+                         // Cursor left, and stop at the left without scrolling
+                         cursorLeft(1, false);
+                     } else {
+                         // IND - Index
+                         ind();
+                     }
+                     break;
+                 case 'E':
+                     if (vt52Mode == true) {
+                         // Nothing
+                     } else {
+                         // NEL - Next line
+                         nel();
+                     }
+                     break;
+                 case 'F':
+                     if (vt52Mode == true) {
+                         // G0 --> Special graphics
+                         currentState.g0Charset = CharacterSet.VT52_GRAPHICS;
+                     }
+                     break;
+                 case 'G':
+                     if (vt52Mode == true) {
+                         // G0 --> ASCII set
+                         currentState.g0Charset = CharacterSet.US;
+                     }
+                     break;
+                 case 'H':
+                     if (vt52Mode == true) {
+                         // Cursor to home
+                         cursorPosition(0, 0);
+                     } else {
+                         // HTS - Horizontal tabulation set
+                         hts();
+                     }
+                     break;
+                 case 'I':
+                     if (vt52Mode == true) {
+                         // Reverse line feed.  Same as RI.
+                         ri();
+                     }
+                     break;
+                 case 'J':
+                     if (vt52Mode == true) {
+                         // Erase to end of screen
+                         eraseLine(currentState.cursorX, width - 1, false);
+                         eraseScreen(currentState.cursorY + 1, 0, height - 1,
+                             width - 1, false);
+                     }
+                     break;
+                 case 'K':
+                     if (vt52Mode == true) {
+                         // Erase to end of line
+                         eraseLine(currentState.cursorX, width - 1, false);
+                     }
+                     break;
+                 case 'L':
+                     break;
+                 case 'M':
+                     if (vt52Mode == true) {
+                         // Nothing
+                     } else {
+                         // RI - Reverse index
+                         ri();
+                     }
+                     break;
+                 case 'N':
+                     if (vt52Mode == false) {
+                         // SS2
+                         singleshift = Singleshift.SS2;
+                     }
+                     break;
+                 case 'O':
+                     if (vt52Mode == false) {
+                         // SS3
+                         singleshift = Singleshift.SS3;
+                     }
+                     break;
+                 }
+                 toGround();
+                 return;
+             }
+             if ((ch >= 0x51) && (ch <= 0x57)) {
+                 switch (ch) {
+                 case 'Q':
+                 case 'R':
+                 case 'S':
+                 case 'T':
+                 case 'U':
+                 case 'V':
+                 case 'W':
+                     break;
+                 }
+                 toGround();
+                 return;
+             }
+             if (ch == 0x59) {
+                 // 'Y'
+                 if (vt52Mode == true) {
+                     scanState = ScanState.VT52_DIRECT_CURSOR_ADDRESS;
+                 } else {
+                     toGround();
+                 }
+                 return;
+             }
+             if (ch == 0x5A) {
+                 // 'Z'
+                 if (vt52Mode == true) {
+                     // Identify
+                     // Send string directly to remote side
+                     writeRemote("\033/Z");
+                 } else {
+                     // DECID
+                     // Send string directly to remote side
+                     writeRemote(deviceTypeResponse());
+                 }
+                 toGround();
+                 return;
+             }
+             if (ch == 0x5C) {
+                 // '\'
+                 toGround();
+                 return;
+             }
+             // VT52 cannot get to any of these other states
+             if (vt52Mode == true) {
+                 toGround();
+                 return;
+             }
+             if ((ch >= 0x60) && (ch <= 0x7E)) {
+                 switch (ch) {
+                 case '`':
+                 case 'a':
+                 case 'b':
+                     break;
+                 case 'c':
+                     // RIS - Reset to initial state
+                     reset();
+                     // Do I clear screen too? I think so...
+                     eraseScreen(0, 0, height - 1, width - 1, false);
+                     cursorPosition(0, 0);
+                     break;
+                 case 'd':
+                 case 'e':
+                 case 'f':
+                 case 'g':
+                 case 'h':
+                 case 'i':
+                 case 'j':
+                 case 'k':
+                 case 'l':
+                 case 'm':
+                     break;
+                 case 'n':
+                     if ((type == DeviceType.VT220)
+                         || (type == DeviceType.XTERM)) {
+                         // VT220 lockshift G2 into GL
+                         currentState.glLockshift = LockshiftMode.G2_GL;
+                         shiftOut = false;
+                     }
+                     break;
+                 case 'o':
+                     if ((type == DeviceType.VT220)
+                         || (type == DeviceType.XTERM)) {
+                         // VT220 lockshift G3 into GL
+                         currentState.glLockshift = LockshiftMode.G3_GL;
+                         shiftOut = false;
+                     }
+                     break;
+                 case 'p':
+                 case 'q':
+                 case 'r':
+                 case 's':
+                 case 't':
+                 case 'u':
+                 case 'v':
+                 case 'w':
+                 case 'x':
+                 case 'y':
+                 case 'z':
+                 case '{':
+                     break;
+                 case '|':
+                     if ((type == DeviceType.VT220)
+                         || (type == DeviceType.XTERM)) {
+                         // VT220 lockshift G3 into GR
+                         currentState.grLockshift = LockshiftMode.G3_GR;
+                         shiftOut = false;
+                     }
+                     break;
+                 case '}':
+                     if ((type == DeviceType.VT220)
+                         || (type == DeviceType.XTERM)) {
+                         // VT220 lockshift G2 into GR
+                         currentState.grLockshift = LockshiftMode.G2_GR;
+                         shiftOut = false;
+                     }
+                     break;
+                 case '~':
+                     if ((type == DeviceType.VT220)
+                         || (type == DeviceType.XTERM)) {
+                         // VT220 lockshift G1 into GR
+                         currentState.grLockshift = LockshiftMode.G1_GR;
+                         shiftOut = false;
+                     }
+                     break;
+                 }
+                 toGround();
+             }
+             // 7F               --> ignore
+             // 0x5B goes to CSI_ENTRY
+             if (ch == 0x5B) {
+                 scanState = ScanState.CSI_ENTRY;
+             }
+             // 0x5D goes to OSC_STRING
+             if (ch == 0x5D) {
+                 scanState = ScanState.OSC_STRING;
+             }
+             // 0x50 goes to DCS_ENTRY
+             if (ch == 0x50) {
+                 scanState = ScanState.DCS_ENTRY;
+             }
+             // 0x58, 0x5E, and 0x5F go to SOSPMAPC_STRING
+             if ((ch == 0x58) || (ch == 0x5E) || (ch == 0x5F)) {
+                 scanState = ScanState.SOSPMAPC_STRING;
+             }
+             return;
+         case ESCAPE_INTERMEDIATE:
+             // 00-17, 19, 1C-1F    --> execute
+             if (ch <= 0x1F) {
+                 handleControlChar((char) ch);
+             }
+             // 20-2F               --> collect
+             if ((ch >= 0x20) && (ch <= 0x2F)) {
+                 collect((char) ch);
+             }
+             // 30-7E               --> dispatch, then switch to GROUND
+             if ((ch >= 0x30) && (ch <= 0x7E)) {
+                 switch (ch) {
+                 case '0':
+                     if ((collectBuffer.length() == 1)
+                         && (collectBuffer.charAt(0) == '(')) {
+                         // G0 --> Special graphics
+                         currentState.g0Charset = CharacterSet.DRAWING;
+                     }
+                     if ((collectBuffer.length() == 1)
+                         && (collectBuffer.charAt(0) == ')')) {
+                         // G1 --> Special graphics
+                         currentState.g1Charset = CharacterSet.DRAWING;
+                     }
+                     if ((type == DeviceType.VT220)
+                         || (type == DeviceType.XTERM)) {
+                         if ((collectBuffer.length() == 1)
+                             && (collectBuffer.charAt(0) == '*')) {
+                             // G2 --> Special graphics
+                             currentState.g2Charset = CharacterSet.DRAWING;
+                         }
+                         if ((collectBuffer.length() == 1)
+                             && (collectBuffer.charAt(0) == '+')) {
+                             // G3 --> Special graphics
+                             currentState.g3Charset = CharacterSet.DRAWING;
+                         }
+                     }
+                     break;
+                 case '1':
+                     if ((collectBuffer.length() == 1)
+                         && (collectBuffer.charAt(0) == '(')) {
+                         // G0 --> Alternate character ROM standard character set
+                         currentState.g0Charset = CharacterSet.ROM;
+                     }
+                     if ((collectBuffer.length() == 1)
+                         && (collectBuffer.charAt(0) == ')')) {
+                         // G1 --> Alternate character ROM standard character set
+                         currentState.g1Charset = CharacterSet.ROM;
+                     }
+                     break;
+                 case '2':
+                     if ((collectBuffer.length() == 1)
+                         && (collectBuffer.charAt(0) == '(')) {
+                         // G0 --> Alternate character ROM special graphics
+                         currentState.g0Charset = CharacterSet.ROM_SPECIAL;
+                     }
+                     if ((collectBuffer.length() == 1)
+                         && (collectBuffer.charAt(0) == ')')) {
+                         // G1 --> Alternate character ROM special graphics
+                         currentState.g1Charset = CharacterSet.ROM_SPECIAL;
+                     }
+                     break;
+                 case '3':
+                     if ((collectBuffer.length() == 1)
+                         && (collectBuffer.charAt(0) == '#')) {
+                         // DECDHL - Double-height line (top half)
+                         dechdl(true);
+                     }
+                     break;
+                 case '4':
+                     if ((collectBuffer.length() == 1)
+                         && (collectBuffer.charAt(0) == '#')) {
+                         // DECDHL - Double-height line (bottom half)
+                         dechdl(false);
+                     }
+                     if ((type == DeviceType.VT220)
+                         || (type == DeviceType.XTERM)) {
+                         if ((collectBuffer.length() == 1)
+                             && (collectBuffer.charAt(0) == '(')) {
+                             // G0 --> DUTCH
+                             currentState.g0Charset = CharacterSet.NRC_DUTCH;
+                         }
+                         if ((collectBuffer.length() == 1)
+                             && (collectBuffer.charAt(0) == ')')) {
+                             // G1 --> DUTCH
+                             currentState.g1Charset = CharacterSet.NRC_DUTCH;
+                         }
+                         if ((collectBuffer.length() == 1)
+                             && (collectBuffer.charAt(0) == '*')) {
+                             // G2 --> DUTCH
+                             currentState.g2Charset = CharacterSet.NRC_DUTCH;
+                         }
+                         if ((collectBuffer.length() == 1)
+                             && (collectBuffer.charAt(0) == '+')) {
+                             // G3 --> DUTCH
+                             currentState.g3Charset = CharacterSet.NRC_DUTCH;
+                         }
+                     }
+                     break;
+                 case '5':
+                     if ((collectBuffer.length() == 1)
+                         && (collectBuffer.charAt(0) == '#')) {
+                         // DECSWL - Single-width line
+                         decswl();
+                     }
+                     if ((type == DeviceType.VT220)
+                         || (type == DeviceType.XTERM)) {
+                         if ((collectBuffer.length() == 1)
+                             && (collectBuffer.charAt(0) == '(')) {
+                             // G0 --> FINNISH
+                             currentState.g0Charset = CharacterSet.NRC_FINNISH;
+                         }
+                         if ((collectBuffer.length() == 1)
+                             && (collectBuffer.charAt(0) == ')')) {
+                             // G1 --> FINNISH
+                             currentState.g1Charset = CharacterSet.NRC_FINNISH;
+                         }
+                         if ((collectBuffer.length() == 1)
+                             && (collectBuffer.charAt(0) == '*')) {
+                             // G2 --> FINNISH
+                             currentState.g2Charset = CharacterSet.NRC_FINNISH;
+                         }
+                         if ((collectBuffer.length() == 1)
+                             && (collectBuffer.charAt(0) == '+')) {
+                             // G3 --> FINNISH
+                             currentState.g3Charset = CharacterSet.NRC_FINNISH;
+                         }
+                     }
+                     break;
+                 case '6':
+                     if ((collectBuffer.length() == 1)
+                         && (collectBuffer.charAt(0) == '#')) {
+                         // DECDWL - Double-width line
+                         decdwl();
+                     }
+                     if ((type == DeviceType.VT220)
+                         || (type == DeviceType.XTERM)) {
+                         if ((collectBuffer.length() == 1)
+                             && (collectBuffer.charAt(0) == '(')) {
+                             // G0 --> NORWEGIAN
+                             currentState.g0Charset = CharacterSet.NRC_NORWEGIAN;
+                         }
+                         if ((collectBuffer.length() == 1)
+                             && (collectBuffer.charAt(0) == ')')) {
+                             // G1 --> NORWEGIAN
+                             currentState.g1Charset = CharacterSet.NRC_NORWEGIAN;
+                         }
+                         if ((collectBuffer.length() == 1)
+                             && (collectBuffer.charAt(0) == '*')) {
+                             // G2 --> NORWEGIAN
+                             currentState.g2Charset = CharacterSet.NRC_NORWEGIAN;
+                         }
+                         if ((collectBuffer.length() == 1)
+                             && (collectBuffer.charAt(0) == '+')) {
+                             // G3 --> NORWEGIAN
+                             currentState.g3Charset = CharacterSet.NRC_NORWEGIAN;
+                         }
+                     }
+                     break;
+                 case '7':
+                     if ((type == DeviceType.VT220)
+                         || (type == DeviceType.XTERM)) {
+                         if ((collectBuffer.length() == 1)
+                             && (collectBuffer.charAt(0) == '(')) {
+                             // G0 --> SWEDISH
+                             currentState.g0Charset = CharacterSet.NRC_SWEDISH;
+                         }
+                         if ((collectBuffer.length() == 1)
+                             && (collectBuffer.charAt(0) == ')')) {
+                             // G1 --> SWEDISH
+                             currentState.g1Charset = CharacterSet.NRC_SWEDISH;
+                         }
+                         if ((collectBuffer.length() == 1)
+                             && (collectBuffer.charAt(0) == '*')) {
+                             // G2 --> SWEDISH
+                             currentState.g2Charset = CharacterSet.NRC_SWEDISH;
+                         }
+                         if ((collectBuffer.length() == 1)
+                             && (collectBuffer.charAt(0) == '+')) {
+                             // G3 --> SWEDISH
+                             currentState.g3Charset = CharacterSet.NRC_SWEDISH;
+                         }
+                     }
+                     break;
+                 case '8':
+                     if ((collectBuffer.length() == 1)
+                         && (collectBuffer.charAt(0) == '#')) {
+                         // DECALN - Screen alignment display
+                         decaln();
+                     }
+                     break;
+                 case '9':
+                 case ':':
+                 case ';':
+                     break;
+                 case '<':
+                     if ((type == DeviceType.VT220)
+                         || (type == DeviceType.XTERM)) {
+                         if ((collectBuffer.length() == 1)
+                             && (collectBuffer.charAt(0) == '(')) {
+                             // G0 --> DEC_SUPPLEMENTAL
+                             currentState.g0Charset = CharacterSet.DEC_SUPPLEMENTAL;
+                         }
+                         if ((collectBuffer.length() == 1)
+                             && (collectBuffer.charAt(0) == ')')) {
+                             // G1 --> DEC_SUPPLEMENTAL
+                             currentState.g1Charset = CharacterSet.DEC_SUPPLEMENTAL;
+                         }
+                         if ((collectBuffer.length() == 1)
+                             && (collectBuffer.charAt(0) == '*')) {
+                             // G2 --> DEC_SUPPLEMENTAL
+                             currentState.g2Charset = CharacterSet.DEC_SUPPLEMENTAL;
+                         }
+                         if ((collectBuffer.length() == 1)
+                             && (collectBuffer.charAt(0) == '+')) {
+                             // G3 --> DEC_SUPPLEMENTAL
+                             currentState.g3Charset = CharacterSet.DEC_SUPPLEMENTAL;
+                         }
+                     }
+                     break;
+                 case '=':
+                     if ((type == DeviceType.VT220)
+                         || (type == DeviceType.XTERM)) {
+                         if ((collectBuffer.length() == 1)
+                             && (collectBuffer.charAt(0) == '(')) {
+                             // G0 --> SWISS
+                             currentState.g0Charset = CharacterSet.NRC_SWISS;
+                         }
+                         if ((collectBuffer.length() == 1)
+                             && (collectBuffer.charAt(0) == ')')) {
+                             // G1 --> SWISS
+                             currentState.g1Charset = CharacterSet.NRC_SWISS;
+                         }
+                         if ((collectBuffer.length() == 1)
+                             && (collectBuffer.charAt(0) == '*')) {
+                             // G2 --> SWISS
+                             currentState.g2Charset = CharacterSet.NRC_SWISS;
+                         }
+                         if ((collectBuffer.length() == 1)
+                             && (collectBuffer.charAt(0) == '+')) {
+                             // G3 --> SWISS
+                             currentState.g3Charset = CharacterSet.NRC_SWISS;
+                         }
+                     }
+                     break;
+                 case '>':
+                 case '?':
+                 case '@':
+                     break;
+                 case 'A':
+                     if ((collectBuffer.length() == 1)
+                         && (collectBuffer.charAt(0) == '(')) {
+                         // G0 --> United Kingdom set
+                         currentState.g0Charset = CharacterSet.UK;
+                     }
+                     if ((collectBuffer.length() == 1)
+                         && (collectBuffer.charAt(0) == ')')) {
+                         // G1 --> United Kingdom set
+                         currentState.g1Charset = CharacterSet.UK;
+                     }
+                     if ((type == DeviceType.VT220)
+                         || (type == DeviceType.XTERM)) {
+                         if ((collectBuffer.length() == 1)
+                             && (collectBuffer.charAt(0) == '*')) {
+                             // G2 --> United Kingdom set
+                             currentState.g2Charset = CharacterSet.UK;
+                         }
+                         if ((collectBuffer.length() == 1)
+                             && (collectBuffer.charAt(0) == '+')) {
+                             // G3 --> United Kingdom set
+                             currentState.g3Charset = CharacterSet.UK;
+                         }
+                     }
+                     break;
+                 case 'B':
+                     if ((collectBuffer.length() == 1)
+                         && (collectBuffer.charAt(0) == '(')) {
+                         // G0 --> ASCII set
+                         currentState.g0Charset = CharacterSet.US;
+                     }
+                     if ((collectBuffer.length() == 1)
+                         && (collectBuffer.charAt(0) == ')')) {
+                         // G1 --> ASCII set
+                         currentState.g1Charset = CharacterSet.US;
+                     }
+                     if ((type == DeviceType.VT220)
+                         || (type == DeviceType.XTERM)) {
+                         if ((collectBuffer.length() == 1)
+                             && (collectBuffer.charAt(0) == '*')) {
+                             // G2 --> ASCII
+                             currentState.g2Charset = CharacterSet.US;
+                         }
+                         if ((collectBuffer.length() == 1)
+                             && (collectBuffer.charAt(0) == '+')) {
+                             // G3 --> ASCII
+                             currentState.g3Charset = CharacterSet.US;
+                         }
+                     }
+                     break;
+                 case 'C':
+                     if ((type == DeviceType.VT220)
+                         || (type == DeviceType.XTERM)) {
+                         if ((collectBuffer.length() == 1)
+                             && (collectBuffer.charAt(0) == '(')) {
+                             // G0 --> FINNISH
+                             currentState.g0Charset = CharacterSet.NRC_FINNISH;
+                         }
+                         if ((collectBuffer.length() == 1)
+                             && (collectBuffer.charAt(0) == ')')) {
+                             // G1 --> FINNISH
+                             currentState.g1Charset = CharacterSet.NRC_FINNISH;
+                         }
+                         if ((collectBuffer.length() == 1)
+                             && (collectBuffer.charAt(0) == '*')) {
+                             // G2 --> FINNISH
+                             currentState.g2Charset = CharacterSet.NRC_FINNISH;
+                         }
+                         if ((collectBuffer.length() == 1)
+                             && (collectBuffer.charAt(0) == '+')) {
+                             // G3 --> FINNISH
+                             currentState.g3Charset = CharacterSet.NRC_FINNISH;
+                         }
+                     }
+                     break;
+                 case 'D':
+                     break;
+                 case 'E':
+                     if ((type == DeviceType.VT220)
+                         || (type == DeviceType.XTERM)) {
+                         if ((collectBuffer.length() == 1)
+                             && (collectBuffer.charAt(0) == '(')) {
+                             // G0 --> NORWEGIAN
+                             currentState.g0Charset = CharacterSet.NRC_NORWEGIAN;
+                         }
+                         if ((collectBuffer.length() == 1)
+                             && (collectBuffer.charAt(0) == ')')) {
+                             // G1 --> NORWEGIAN
+                             currentState.g1Charset = CharacterSet.NRC_NORWEGIAN;
+                         }
+                         if ((collectBuffer.length() == 1)
+                             && (collectBuffer.charAt(0) == '*')) {
+                             // G2 --> NORWEGIAN
+                             currentState.g2Charset = CharacterSet.NRC_NORWEGIAN;
+                         }
+                         if ((collectBuffer.length() == 1)
+                             && (collectBuffer.charAt(0) == '+')) {
+                             // G3 --> NORWEGIAN
+                             currentState.g3Charset = CharacterSet.NRC_NORWEGIAN;
+                         }
+                     }
+                     break;
+                 case 'F':
+                     if ((type == DeviceType.VT220)
+                         || (type == DeviceType.XTERM)) {
+                         if ((collectBuffer.length() == 1)
+                             && (collectBuffer.charAt(0) == ' ')) {
+                             // S7C1T
+                             s8c1t = false;
+                         }
+                     }
+                     break;
+                 case 'G':
+                     if ((type == DeviceType.VT220)
+                         || (type == DeviceType.XTERM)) {
+                         if ((collectBuffer.length() == 1)
+                             && (collectBuffer.charAt(0) == ' ')) {
+                             // S8C1T
+                             s8c1t = true;
+                         }
+                     }
+                     break;
+                 case 'H':
+                     if ((type == DeviceType.VT220)
+                         || (type == DeviceType.XTERM)) {
+                         if ((collectBuffer.length() == 1)
+                             && (collectBuffer.charAt(0) == '(')) {
+                             // G0 --> SWEDISH
+                             currentState.g0Charset = CharacterSet.NRC_SWEDISH;
+                         }
+                         if ((collectBuffer.length() == 1)
+                             && (collectBuffer.charAt(0) == ')')) {
+                             // G1 --> SWEDISH
+                             currentState.g1Charset = CharacterSet.NRC_SWEDISH;
+                         }
+                         if ((collectBuffer.length() == 1)
+                             && (collectBuffer.charAt(0) == '*')) {
+                             // G2 --> SWEDISH
+                             currentState.g2Charset = CharacterSet.NRC_SWEDISH;
+                         }
+                         if ((collectBuffer.length() == 1)
+                             && (collectBuffer.charAt(0) == '+')) {
+                             // G3 --> SWEDISH
+                             currentState.g3Charset = CharacterSet.NRC_SWEDISH;
+                         }
+                     }
+                     break;
+                 case 'I':
+                 case 'J':
+                     break;
+                 case 'K':
+                     if ((type == DeviceType.VT220)
+                         || (type == DeviceType.XTERM)) {
+                         if ((collectBuffer.length() == 1)
+                             && (collectBuffer.charAt(0) == '(')) {
+                             // G0 --> GERMAN
+                             currentState.g0Charset = CharacterSet.NRC_GERMAN;
+                         }
+                         if ((collectBuffer.length() == 1)
+                             && (collectBuffer.charAt(0) == ')')) {
+                             // G1 --> GERMAN
+                             currentState.g1Charset = CharacterSet.NRC_GERMAN;
+                         }
+                         if ((collectBuffer.length() == 1)
+                             && (collectBuffer.charAt(0) == '*')) {
+                             // G2 --> GERMAN
+                             currentState.g2Charset = CharacterSet.NRC_GERMAN;
+                         }
+                         if ((collectBuffer.length() == 1)
+                             && (collectBuffer.charAt(0) == '+')) {
+                             // G3 --> GERMAN
+                             currentState.g3Charset = CharacterSet.NRC_GERMAN;
+                         }
+                     }
+                     break;
+                 case 'L':
+                 case 'M':
+                 case 'N':
+                 case 'O':
+                 case 'P':
+                     break;
+                 case 'Q':
+                     if ((type == DeviceType.VT220)
+                         || (type == DeviceType.XTERM)) {
+                         if ((collectBuffer.length() == 1)
+                             && (collectBuffer.charAt(0) == '(')) {
+                             // G0 --> FRENCH_CA
+                             currentState.g0Charset = CharacterSet.NRC_FRENCH_CA;
+                         }
+                         if ((collectBuffer.length() == 1)
+                             && (collectBuffer.charAt(0) == ')')) {
+                             // G1 --> FRENCH_CA
+                             currentState.g1Charset = CharacterSet.NRC_FRENCH_CA;
+                         }
+                         if ((collectBuffer.length() == 1)
+                             && (collectBuffer.charAt(0) == '*')) {
+                             // G2 --> FRENCH_CA
+                             currentState.g2Charset = CharacterSet.NRC_FRENCH_CA;
+                         }
+                         if ((collectBuffer.length() == 1)
+                             && (collectBuffer.charAt(0) == '+')) {
+                             // G3 --> FRENCH_CA
+                             currentState.g3Charset = CharacterSet.NRC_FRENCH_CA;
+                         }
+                     }
+                     break;
+                 case 'R':
+                     if ((type == DeviceType.VT220)
+                         || (type == DeviceType.XTERM)) {
+                         if ((collectBuffer.length() == 1)
+                             && (collectBuffer.charAt(0) == '(')) {
+                             // G0 --> FRENCH
+                             currentState.g0Charset = CharacterSet.NRC_FRENCH;
+                         }
+                         if ((collectBuffer.length() == 1)
+                             && (collectBuffer.charAt(0) == ')')) {
+                             // G1 --> FRENCH
+                             currentState.g1Charset = CharacterSet.NRC_FRENCH;
+                         }
+                         if ((collectBuffer.length() == 1)
+                             && (collectBuffer.charAt(0) == '*')) {
+                             // G2 --> FRENCH
+                             currentState.g2Charset = CharacterSet.NRC_FRENCH;
+                         }
+                         if ((collectBuffer.length() == 1)
+                             && (collectBuffer.charAt(0) == '+')) {
+                             // G3 --> FRENCH
+                             currentState.g3Charset = CharacterSet.NRC_FRENCH;
+                         }
+                     }
+                     break;
+                 case 'S':
+                 case 'T':
+                 case 'U':
+                 case 'V':
+                 case 'W':
+                 case 'X':
+                     break;
+                 case 'Y':
+                     if ((type == DeviceType.VT220)
+                         || (type == DeviceType.XTERM)) {
+                         if ((collectBuffer.length() == 1)
+                             && (collectBuffer.charAt(0) == '(')) {
+                             // G0 --> ITALIAN
+                             currentState.g0Charset = CharacterSet.NRC_ITALIAN;
+                         }
+                         if ((collectBuffer.length() == 1)
+                             && (collectBuffer.charAt(0) == ')')) {
+                             // G1 --> ITALIAN
+                             currentState.g1Charset = CharacterSet.NRC_ITALIAN;
+                         }
+                         if ((collectBuffer.length() == 1)
+                             && (collectBuffer.charAt(0) == '*')) {
+                             // G2 --> ITALIAN
+                             currentState.g2Charset = CharacterSet.NRC_ITALIAN;
+                         }
+                         if ((collectBuffer.length() == 1)
+                             && (collectBuffer.charAt(0) == '+')) {
+                             // G3 --> ITALIAN
+                             currentState.g3Charset = CharacterSet.NRC_ITALIAN;
+                         }
+                     }
+                     break;
+                 case 'Z':
+                     if ((type == DeviceType.VT220)
+                         || (type == DeviceType.XTERM)) {
+                         if ((collectBuffer.length() == 1)
+                             && (collectBuffer.charAt(0) == '(')) {
+                             // G0 --> SPANISH
+                             currentState.g0Charset = CharacterSet.NRC_SPANISH;
+                         }
+                         if ((collectBuffer.length() == 1)
+                             && (collectBuffer.charAt(0) == ')')) {
+                             // G1 --> SPANISH
+                             currentState.g1Charset = CharacterSet.NRC_SPANISH;
+                         }
+                         if ((collectBuffer.length() == 1)
+                             && (collectBuffer.charAt(0) == '*')) {
+                             // G2 --> SPANISH
+                             currentState.g2Charset = CharacterSet.NRC_SPANISH;
+                         }
+                         if ((collectBuffer.length() == 1)
+                             && (collectBuffer.charAt(0) == '+')) {
+                             // G3 --> SPANISH
+                             currentState.g3Charset = CharacterSet.NRC_SPANISH;
+                         }
+                     }
+                     break;
+                 case '[':
+                 case '\\':
+                 case ']':
+                 case '^':
+                 case '_':
+                 case '`':
+                 case 'a':
+                 case 'b':
+                 case 'c':
+                 case 'd':
+                 case 'e':
+                 case 'f':
+                 case 'g':
+                 case 'h':
+                 case 'i':
+                 case 'j':
+                 case 'k':
+                 case 'l':
+                 case 'm':
+                 case 'n':
+                 case 'o':
+                 case 'p':
+                 case 'q':
+                 case 'r':
+                 case 's':
+                 case 't':
+                 case 'u':
+                 case 'v':
+                 case 'w':
+                 case 'x':
+                 case 'y':
+                 case 'z':
+                 case '{':
+                 case '|':
+                 case '}':
+                 case '~':
+                     break;
+                 }
+                 toGround();
+             }
+             // 7F                  --> ignore
+             // 0x9C goes to GROUND
+             if (ch == 0x9C) {
+                 toGround();
+             }
+             return;
+         case CSI_ENTRY:
+             // 00-17, 19, 1C-1F    --> execute
+             if (ch <= 0x1F) {
+                 handleControlChar((char) ch);
+             }
+             // 20-2F               --> collect, then switch to CSI_INTERMEDIATE
+             if ((ch >= 0x20) && (ch <= 0x2F)) {
+                 collect((char) ch);
+                 scanState = ScanState.CSI_INTERMEDIATE;
+             }
+             // 30-39, 3B           --> param, then switch to CSI_PARAM
+             if ((ch >= '0') && (ch <= '9')) {
+                 param((byte) ch);
+                 scanState = ScanState.CSI_PARAM;
+             }
+             if (ch == ';') {
+                 param((byte) ch);
+                 scanState = ScanState.CSI_PARAM;
+             }
+             // 3C-3F               --> collect, then switch to CSI_PARAM
+             if ((ch >= 0x3C) && (ch <= 0x3F)) {
+                 collect((char) ch);
+                 scanState = ScanState.CSI_PARAM;
+             }
+             // 40-7E               --> dispatch, then switch to GROUND
+             if ((ch >= 0x40) && (ch <= 0x7E)) {
+                 switch (ch) {
+                 case '@':
+                     // ICH - Insert character
+                     ich();
+                     break;
+                 case 'A':
+                     // CUU - Cursor up
+                     cuu();
+                     break;
+                 case 'B':
+                     // CUD - Cursor down
+                     cud();
+                     break;
+                 case 'C':
+                     // CUF - Cursor forward
+                     cuf();
+                     break;
+                 case 'D':
+                     // CUB - Cursor backward
+                     cub();
+                     break;
+                 case 'E':
+                     // CNL - Cursor down and to column 1
+                     if (type == DeviceType.XTERM) {
+                         cnl();
+                     }
+                     break;
+                 case 'F':
+                     // CPL - Cursor up and to column 1
+                     if (type == DeviceType.XTERM) {
+                         cpl();
+                     }
+                     break;
+                 case 'G':
+                     // CHA - Cursor to column # in current row
+                     if (type == DeviceType.XTERM) {
+                         cha();
+                     }
+                     break;
+                 case 'H':
+                     // CUP - Cursor position
+                     cup();
+                     break;
+                 case 'I':
+                     // CHT - Cursor forward X tab stops (default 1)
+                     if (type == DeviceType.XTERM) {
+                         cht();
+                     }
+                     break;
+                 case 'J':
+                     // ED - Erase in display
+                     ed();
+                     break;
+                 case 'K':
+                     // EL - Erase in line
+                     el();
+                     break;
+                 case 'L':
+                     // IL - Insert line
+                     il();
+                     break;
+                 case 'M':
+                     // DL - Delete line
+                     dl();
+                     break;
+                 case 'N':
+                 case 'O':
+                     break;
+                 case 'P':
+                     // DCH - Delete character
+                     dch();
+                     break;
+                 case 'Q':
+                 case 'R':
+                     break;
+                 case 'S':
+                     // Scroll up X lines (default 1)
+                     if (type == DeviceType.XTERM) {
+                         boolean xtermPrivateModeFlag = false;
+                         for (int i = 0; i < collectBuffer.length(); i++) {
+                             if (collectBuffer.charAt(i) == '?') {
+                                 xtermPrivateModeFlag = true;
+                                 break;
+                             }
+                         }
+                         if (xtermPrivateModeFlag) {
+                             xtermSixelQuery();
+                         } else {
+                             su();
+                         }
+                     }
+                     break;
+                 case 'T':
+                     // Scroll down X lines (default 1)
+                     if (type == DeviceType.XTERM) {
+                         sd();
+                     }
+                     break;
+                 case 'U':
+                 case 'V':
+                 case 'W':
+                     break;
+                 case 'X':
+                     if ((type == DeviceType.VT220)
+                         || (type == DeviceType.XTERM)) {
+                         // ECH - Erase character
+                         ech();
+                     }
+                     break;
+                 case 'Y':
+                     break;
+                 case 'Z':
+                     // CBT - Cursor backward X tab stops (default 1)
+                     if (type == DeviceType.XTERM) {
+                         cbt();
+                     }
+                     break;
+                 case '[':
+                 case '\\':
+                 case ']':
+                 case '^':
+                 case '_':
+                     break;
+                 case '`':
+                     // HPA - Cursor to column # in current row.  Same as CHA
+                     if (type == DeviceType.XTERM) {
+                         cha();
+                     }
+                     break;
+                 case 'a':
+                     // HPR - Cursor right.  Same as CUF
+                     if (type == DeviceType.XTERM) {
+                         cuf();
+                     }
+                     break;
+                 case 'b':
+                     // REP - Repeat last char X times
+                     if (type == DeviceType.XTERM) {
+                         rep();
+                     }
+                     break;
+                 case 'c':
+                     // DA - Device attributes
+                     da();
+                     break;
+                 case 'd':
+                     // VPA - Cursor to row, current column.
+                     if (type == DeviceType.XTERM) {
+                         vpa();
+                     }
+                     break;
+                 case 'e':
+                     // VPR - Cursor down.  Same as CUD
+                     if (type == DeviceType.XTERM) {
+                         cud();
+                     }
+                     break;
+                 case 'f':
+                     // HVP - Horizontal and vertical position
+                     hvp();
+                     break;
+                 case 'g':
+                     // TBC - Tabulation clear
+                     tbc();
+                     break;
+                 case 'h':
+                     // Sets an ANSI or DEC private toggle
+                     setToggle(true);
+                     break;
+                 case 'i':
+                     if ((type == DeviceType.VT220)
+                         || (type == DeviceType.XTERM)) {
+                         // Printer functions
+                         printerFunctions();
+                     }
+                     break;
+                 case 'j':
+                 case 'k':
+                     break;
+                 case 'l':
+                     // Sets an ANSI or DEC private toggle
+                     setToggle(false);
+                     break;
+                 case 'm':
+                     // SGR - Select graphics rendition
+                     sgr();
+                     break;
+                 case 'n':
+                     // DSR - Device status report
+                     dsr();
+                     break;
+                 case 'o':
+                 case 'p':
+                     break;
+                 case 'q':
+                     // DECLL - Load leds
+                     // Not supported
+                     break;
+                 case 'r':
+                     // DECSTBM - Set top and bottom margins
+                     decstbm();
+                     break;
+                 case 's':
+                     // Save cursor (ANSI.SYS)
+                     if (type == DeviceType.XTERM) {
+                         savedState.cursorX = currentState.cursorX;
+                         savedState.cursorY = currentState.cursorY;
+                     }
+                     break;
+                 case 't':
+                     if (type == DeviceType.XTERM) {
+                         // Window operations
+                         xtermWindowOps();
+                     }
+                     break;
+                 case 'u':
+                     // Restore cursor (ANSI.SYS)
+                     if (type == DeviceType.XTERM) {
+                         cursorPosition(savedState.cursorY, savedState.cursorX);
+                     }
+                     break;
+                 case 'v':
+                 case 'w':
+                     break;
+                 case 'x':
+                     // DECREQTPARM - Request terminal parameters
+                     decreqtparm();
+                     break;
+                 case 'y':
+                 case 'z':
+                 case '{':
+                 case '|':
+                 case '}':
+                 case '~':
+                     break;
+                 }
+                 toGround();
+             }
+             // 7F                  --> ignore
+             // 0x9C goes to GROUND
+             if (ch == 0x9C) {
+                 toGround();
+             }
+             // 0x3A goes to CSI_IGNORE
+             if (ch == 0x3A) {
+                 scanState = ScanState.CSI_IGNORE;
+             }
+             return;
+         case CSI_PARAM:
+             // 00-17, 19, 1C-1F    --> execute
+             if (ch <= 0x1F) {
+                 handleControlChar((char) ch);
+             }
+             // 20-2F               --> collect, then switch to CSI_INTERMEDIATE
+             if ((ch >= 0x20) && (ch <= 0x2F)) {
+                 collect((char) ch);
+                 scanState = ScanState.CSI_INTERMEDIATE;
+             }
+             // 30-39, 3B           --> param
+             if ((ch >= '0') && (ch <= '9')) {
+                 param((byte) ch);
+             }
+             if (ch == ';') {
+                 param((byte) ch);
+             }
+             // 0x3A goes to CSI_IGNORE
+             if (ch == 0x3A) {
+                 scanState = ScanState.CSI_IGNORE;
+             }
+             // 0x3C-3F goes to CSI_IGNORE
+             if ((ch >= 0x3C) && (ch <= 0x3F)) {
+                 scanState = ScanState.CSI_IGNORE;
+             }
+             // 40-7E               --> dispatch, then switch to GROUND
+             if ((ch >= 0x40) && (ch <= 0x7E)) {
+                 switch (ch) {
+                 case '@':
+                     // ICH - Insert character
+                     ich();
+                     break;
+                 case 'A':
+                     // CUU - Cursor up
+                     cuu();
+                     break;
+                 case 'B':
+                     // CUD - Cursor down
+                     cud();
+                     break;
+                 case 'C':
+                     // CUF - Cursor forward
+                     cuf();
+                     break;
+                 case 'D':
+                     // CUB - Cursor backward
+                     cub();
+                     break;
+                 case 'E':
+                     // CNL - Cursor down and to column 1
+                     if (type == DeviceType.XTERM) {
+                         cnl();
+                     }
+                     break;
+                 case 'F':
+                     // CPL - Cursor up and to column 1
+                     if (type == DeviceType.XTERM) {
+                         cpl();
+                     }
+                     break;
+                 case 'G':
+                     // CHA - Cursor to column # in current row
+                     if (type == DeviceType.XTERM) {
+                         cha();
+                     }
+                     break;
+                 case 'H':
+                     // CUP - Cursor position
+                     cup();
+                     break;
+                 case 'I':
+                     // CHT - Cursor forward X tab stops (default 1)
+                     if (type == DeviceType.XTERM) {
+                         cht();
+                     }
+                     break;
+                 case 'J':
+                     // ED - Erase in display
+                     ed();
+                     break;
+                 case 'K':
+                     // EL - Erase in line
+                     el();
+                     break;
+                 case 'L':
+                     // IL - Insert line
+                     il();
+                     break;
+                 case 'M':
+                     // DL - Delete line
+                     dl();
+                     break;
+                 case 'N':
+                 case 'O':
+                     break;
+                 case 'P':
+                     // DCH - Delete character
+                     dch();
+                     break;
+                 case 'Q':
+                 case 'R':
+                     break;
+                 case 'S':
+                     // Scroll up X lines (default 1)
+                     if (type == DeviceType.XTERM) {
+                         boolean xtermPrivateModeFlag = false;
+                         for (int i = 0; i < collectBuffer.length(); i++) {
+                             if (collectBuffer.charAt(i) == '?') {
+                                 xtermPrivateModeFlag = true;
+                                 break;
+                             }
+                         }
+                         if (xtermPrivateModeFlag) {
+                             xtermSixelQuery();
+                         } else {
+                             su();
+                         }
+                     }
+                     break;
+                 case 'T':
+                     // Scroll down X lines (default 1)
+                     if (type == DeviceType.XTERM) {
+                         sd();
+                     }
+                     break;
+                 case 'U':
+                 case 'V':
+                 case 'W':
+                     break;
+                 case 'X':
+                     if ((type == DeviceType.VT220)
+                         || (type == DeviceType.XTERM)) {
+                         // ECH - Erase character
+                         ech();
+                     }
+                     break;
+                 case 'Y':
+                     break;
+                 case 'Z':
+                     // CBT - Cursor backward X tab stops (default 1)
+                     if (type == DeviceType.XTERM) {
+                         cbt();
+                     }
+                     break;
+                 case '[':
+                 case '\\':
+                 case ']':
+                 case '^':
+                 case '_':
+                     break;
+                 case '`':
+                     // HPA - Cursor to column # in current row.  Same as CHA
+                     if (type == DeviceType.XTERM) {
+                         cha();
+                     }
+                     break;
+                 case 'a':
+                     // HPR - Cursor right.  Same as CUF
+                     if (type == DeviceType.XTERM) {
+                         cuf();
+                     }
+                     break;
+                 case 'b':
+                     // REP - Repeat last char X times
+                     if (type == DeviceType.XTERM) {
+                         rep();
+                     }
+                     break;
+                 case 'c':
+                     // DA - Device attributes
+                     da();
+                     break;
+                 case 'd':
+                     // VPA - Cursor to row, current column.
+                     if (type == DeviceType.XTERM) {
+                         vpa();
+                     }
+                     break;
+                 case 'e':
+                     // VPR - Cursor down.  Same as CUD
+                     if (type == DeviceType.XTERM) {
+                         cud();
+                     }
+                     break;
+                 case 'f':
+                     // HVP - Horizontal and vertical position
+                     hvp();
+                     break;
+                 case 'g':
+                     // TBC - Tabulation clear
+                     tbc();
+                     break;
+                 case 'h':
+                     // Sets an ANSI or DEC private toggle
+                     setToggle(true);
+                     break;
+                 case 'i':
+                     if ((type == DeviceType.VT220)
+                         || (type == DeviceType.XTERM)) {
+                         // Printer functions
+                         printerFunctions();
+                     }
+                     break;
+                 case 'j':
+                 case 'k':
+                     break;
+                 case 'l':
+                     // Sets an ANSI or DEC private toggle
+                     setToggle(false);
+                     break;
+                 case 'm':
+                     // SGR - Select graphics rendition
+                     sgr();
+                     break;
+                 case 'n':
+                     // DSR - Device status report
+                     dsr();
+                     break;
+                 case 'o':
+                 case 'p':
+                     break;
+                 case 'q':
+                     // DECLL - Load leds
+                     // Not supported
+                     break;
+                 case 'r':
+                     // DECSTBM - Set top and bottom margins
+                     decstbm();
+                     break;
+                 case 's':
+                     break;
+                 case 't':
+                     if (type == DeviceType.XTERM) {
+                         // Window operations
+                         xtermWindowOps();
+                     }
+                     break;
+                 case 'u':
+                 case 'v':
+                 case 'w':
+                     break;
+                 case 'x':
+                     // DECREQTPARM - Request terminal parameters
+                     decreqtparm();
+                     break;
+                 case 'y':
+                 case 'z':
+                 case '{':
+                 case '|':
+                 case '}':
+                 case '~':
+                     break;
+                 }
+                 toGround();
+             }
+             // 7F                  --> ignore
+             return;
+         case CSI_INTERMEDIATE:
+             // 00-17, 19, 1C-1F    --> execute
+             if (ch <= 0x1F) {
+                 handleControlChar((char) ch);
+             }
+             // 20-2F               --> collect
+             if ((ch >= 0x20) && (ch <= 0x2F)) {
+                 collect((char) ch);
+             }
+             // 0x30-3F goes to CSI_IGNORE
+             if ((ch >= 0x30) && (ch <= 0x3F)) {
+                 scanState = ScanState.CSI_IGNORE;
+             }
+             // 40-7E               --> dispatch, then switch to GROUND
+             if ((ch >= 0x40) && (ch <= 0x7E)) {
+                 switch (ch) {
+                 case '@':
+                 case 'A':
+                 case 'B':
+                 case 'C':
+                 case 'D':
+                 case 'E':
+                 case 'F':
+                 case 'G':
+                 case 'H':
+                 case 'I':
+                 case 'J':
+                 case 'K':
+                 case 'L':
+                 case 'M':
+                 case 'N':
+                 case 'O':
+                 case 'P':
+                 case 'Q':
+                 case 'R':
+                 case 'S':
+                 case 'T':
+                 case 'U':
+                 case 'V':
+                 case 'W':
+                 case 'X':
+                 case 'Y':
+                 case 'Z':
+                 case '[':
+                 case '\\':
+                 case ']':
+                 case '^':
+                 case '_':
+                 case '`':
+                 case 'a':
+                 case 'b':
+                 case 'c':
+                 case 'd':
+                 case 'e':
+                 case 'f':
+                 case 'g':
+                 case 'h':
+                 case 'i':
+                 case 'j':
+                 case 'k':
+                 case 'l':
+                 case 'm':
+                 case 'n':
+                 case 'o':
+                     break;
+                 case 'p':
+                     if (((type == DeviceType.VT220)
+                             || (type == DeviceType.XTERM))
+                         && (collectBuffer.charAt(collectBuffer.length() - 1) == '\"')
+                     ) {
+                         // DECSCL - compatibility level
+                         decscl();
+                     }
+                     if ((type == DeviceType.XTERM)
+                         && (collectBuffer.charAt(collectBuffer.length() - 1) == '!')
+                     ) {
+                         // DECSTR - Soft terminal reset
+                         decstr();
+                     }
+                     break;
+                 case 'q':
+                     if (((type == DeviceType.VT220)
+                             || (type == DeviceType.XTERM))
+                         && (collectBuffer.charAt(collectBuffer.length() - 1) == '\"')
+                     ) {
+                         // DECSCA
+                         decsca();
+                     }
+                     break;
+                 case 'r':
+                 case 's':
+                 case 't':
+                 case 'u':
+                 case 'v':
+                 case 'w':
+                 case 'x':
+                 case 'y':
+                 case 'z':
+                 case '{':
+                 case '|':
+                 case '}':
+                 case '~':
+                     break;
+                 }
+                 toGround();
+             }
+             // 7F                  --> ignore
+             return;
+         case CSI_IGNORE:
+             // 00-17, 19, 1C-1F    --> execute
+             if (ch <= 0x1F) {
+                 handleControlChar((char) ch);
+             }
+             // 20-2F               --> collect
+             if ((ch >= 0x20) && (ch <= 0x2F)) {
+                 collect((char) ch);
+             }
+             // 40-7E               --> ignore, then switch to GROUND
+             if ((ch >= 0x40) && (ch <= 0x7E)) {
+                 toGround();
+             }
+             // 20-3F, 7F           --> ignore
+             return;
+         case DCS_ENTRY:
+             // 0x9C goes to GROUND
+             if (ch == 0x9C) {
+                 toGround();
+             }
+             // 0x1B 0x5C goes to GROUND
+             if (ch == 0x1B) {
+                 collect((char) ch);
+             }
+             if (ch == 0x5C) {
+                 if ((collectBuffer.length() > 0)
+                     && (collectBuffer.charAt(collectBuffer.length() - 1) == 0x1B)
+                 ) {
+                     toGround();
+                 }
+             }
+             // 20-2F               --> collect, then switch to DCS_INTERMEDIATE
+             if ((ch >= 0x20) && (ch <= 0x2F)) {
+                 collect((char) ch);
+                 scanState = ScanState.DCS_INTERMEDIATE;
+             }
+             // 30-39, 3B           --> param, then switch to DCS_PARAM
+             if ((ch >= '0') && (ch <= '9')) {
+                 param((byte) ch);
+                 scanState = ScanState.DCS_PARAM;
+             }
+             if (ch == ';') {
+                 param((byte) ch);
+                 scanState = ScanState.DCS_PARAM;
+             }
+             // 3C-3F               --> collect, then switch to DCS_PARAM
+             if ((ch >= 0x3C) && (ch <= 0x3F)) {
+                 collect((char) ch);
+                 scanState = ScanState.DCS_PARAM;
+             }
+             // 00-17, 19, 1C-1F, 7F    --> ignore
+             // 0x3A goes to DCS_IGNORE
+             if (ch == 0x3F) {
+                 scanState = ScanState.DCS_IGNORE;
+             }
+             // 0x71 goes to DCS_SIXEL
+             if (ch == 0x71) {
+                 sixelParseBuffer = new StringBuilder();
+                 scanState = ScanState.DCS_SIXEL;
+             } else if ((ch >= 0x40) && (ch <= 0x7E)) {
+                 // 0x40-7E goes to DCS_PASSTHROUGH
+                 scanState = ScanState.DCS_PASSTHROUGH;
+             }
+             return;
+         case DCS_INTERMEDIATE:
+             // 0x9C goes to GROUND
+             if (ch == 0x9C) {
+                 toGround();
+             }
+             // 0x1B 0x5C goes to GROUND
+             if (ch == 0x1B) {
+                 collect((char) ch);
+             }
+             if (ch == 0x5C) {
+                 if ((collectBuffer.length() > 0)
+                     && (collectBuffer.charAt(collectBuffer.length() - 1) == 0x1B)
+                 ) {
+                     toGround();
+                 }
+             }
+             // 0x30-3F goes to DCS_IGNORE
+             if ((ch >= 0x30) && (ch <= 0x3F)) {
+                 scanState = ScanState.DCS_IGNORE;
+             }
+             // 0x40-7E goes to DCS_PASSTHROUGH
+             if ((ch >= 0x40) && (ch <= 0x7E)) {
+                 scanState = ScanState.DCS_PASSTHROUGH;
+             }
+             // 00-17, 19, 1C-1F, 7F    --> ignore
+             return;
+         case DCS_PARAM:
+             // 0x9C goes to GROUND
+             if (ch == 0x9C) {
+                 toGround();
+             }
+             // 0x1B 0x5C goes to GROUND
+             if (ch == 0x1B) {
+                 collect((char) ch);
+             }
+             if (ch == 0x5C) {
+                 if ((collectBuffer.length() > 0)
+                     && (collectBuffer.charAt(collectBuffer.length() - 1) == 0x1B)
+                 ) {
+                     toGround();
+                 }
+             }
+             // 20-2F          --> collect, then switch to DCS_INTERMEDIATE
+             if ((ch >= 0x20) && (ch <= 0x2F)) {
+                 collect((char) ch);
+                 scanState = ScanState.DCS_INTERMEDIATE;
+             }
+             // 30-39, 3B      --> param
+             if ((ch >= '0') && (ch <= '9')) {
+                 param((byte) ch);
+             }
+             if (ch == ';') {
+                 param((byte) ch);
+             }
+             // 00-17, 19, 1C-1F, 7F    --> ignore
+             // 0x3A, 3C-3F goes to DCS_IGNORE
+             if (ch == 0x3F) {
+                 scanState = ScanState.DCS_IGNORE;
+             }
+             if ((ch >= 0x3C) && (ch <= 0x3F)) {
+                 scanState = ScanState.DCS_IGNORE;
+             }
+             // 0x71 goes to DCS_SIXEL
+             if (ch == 0x71) {
+                 sixelParseBuffer = new StringBuilder();
+                 scanState = ScanState.DCS_SIXEL;
+             } else if ((ch >= 0x40) && (ch <= 0x7E)) {
+                 // 0x40-7E goes to DCS_PASSTHROUGH
+                 scanState = ScanState.DCS_PASSTHROUGH;
+             }
+             return;
+         case DCS_PASSTHROUGH:
+             // 0x9C goes to GROUND
+             if (ch == 0x9C) {
+                 toGround();
+             }
+             // 0x1B 0x5C goes to GROUND
+             if (ch == 0x1B) {
+                 collect((char) ch);
+             }
+             if (ch == 0x5C) {
+                 if ((collectBuffer.length() > 0)
+                     && (collectBuffer.charAt(collectBuffer.length() - 1) == 0x1B)
+                 ) {
+                     toGround();
+                 }
+             }
+             // 00-17, 19, 1C-1F, 20-7E   --> put
+             if (ch <= 0x17) {
+                 // We ignore all DCS except sixel.
+                 return;
+             }
+             if (ch == 0x19) {
+                 // We ignore all DCS except sixel.
+                 return;
+             }
+             if ((ch >= 0x1C) && (ch <= 0x1F)) {
+                 // We ignore all DCS except sixel.
+                 return;
+             }
+             if ((ch >= 0x20) && (ch <= 0x7E)) {
+                 // We ignore all DCS except sixel.
+                 return;
+             }
+             // 7F                        --> ignore
+             return;
+         case DCS_IGNORE:
+             // 00-17, 19, 1C-1F, 20-7F --> ignore
+             // 0x9C goes to GROUND
+             if (ch == 0x9C) {
+                 toGround();
+             }
+             return;
+         case DCS_SIXEL:
+             // 0x9C goes to GROUND
+             if (ch == 0x9C) {
+                 parseSixel();
+                 toGround();
+                 return;
+             }
+             // 0x1B 0x5C goes to GROUND
+             if (ch == 0x1B) {
+                 collect((char) ch);
+                 return;
+             }
+             if (ch == 0x5C) {
+                 if ((collectBuffer.length() > 0)
+                     && (collectBuffer.charAt(collectBuffer.length() - 1) == 0x1B)
+                 ) {
+                     parseSixel();
+                     toGround();
+                     return;
+                 }
+             }
+             // 00-17, 19, 1C-1F, 20-7E   --> put
+             if ((ch <= 0x17)
+                 || (ch == 0x19)
+                 || ((ch >= 0x1C) && (ch <= 0x1F))
+                 || ((ch >= 0x20) && (ch <= 0x7E))
+             ) {
+                 sixelParseBuffer.append((char) ch);
+             }
+             // 7F                        --> ignore
+             return;
+         case SOSPMAPC_STRING:
+             // 00-17, 19, 1C-1F, 20-7F --> ignore
+             // Special case for Jexer: PM can pass one control character
+             if (ch == 0x1B) {
+                 pmPut((char) ch);
+             }
+             if ((ch >= 0x20) && (ch <= 0x7F)) {
+                 pmPut((char) ch);
+             }
+             // 0x9C goes to GROUND
+             if (ch == 0x9C) {
+                 toGround();
+             }
+             return;
+         case OSC_STRING:
+             // Special case for Xterm: OSC can pass control characters
+             if ((ch == 0x9C) || (ch == 0x07) || (ch == 0x1B)) {
+                 oscPut((char) ch);
+             }
+             // 00-17, 19, 1C-1F        --> ignore
+             // 20-7F                   --> osc_put
+             if ((ch >= 0x20) && (ch <= 0x7F)) {
+                 oscPut((char) ch);
+             }
+             // 0x9C goes to GROUND
+             if (ch == 0x9C) {
+                 toGround();
+             }
+             return;
+         case VT52_DIRECT_CURSOR_ADDRESS:
+             // This is a special case for the VT52 sequence "ESC Y l c"
+             if (collectBuffer.length() == 0) {
+                 collect((char) ch);
+             } else if (collectBuffer.length() == 1) {
+                 // We've got the two characters, one in the buffer and the
+                 // other in ch.
+                 cursorPosition(collectBuffer.charAt(0) - '\040', ch - '\040');
+                 toGround();
+             }
+             return;
+         }
+     }
+     /**
+      * Expose current cursor X to outside world.
+      *
+      * @return current cursor X
+      */
+     public final int getCursorX() {
+         if (display.get(currentState.cursorY).isDoubleWidth()) {
+             return currentState.cursorX * 2;
+         }
+         return currentState.cursorX;
+     }
+     /**
+      * Expose current cursor Y to outside world.
+      *
+      * @return current cursor Y
+      */
+     public final int getCursorY() {
+         return currentState.cursorY;
+     }
+     /**
+      * Returns true if this terminal has requested the mouse pointer be
+      * hidden.
+      *
+      * @return true if this terminal has requested the mouse pointer be
+      * hidden
+      */
+     public final boolean hasHiddenMousePointer() {
+         return hideMousePointer;
+     }
+     /**
+      * Get the mouse protocol.
+      *
+      * @return MouseProtocol.OFF, MouseProtocol.X10, etc.
+      */
+     public MouseProtocol getMouseProtocol() {
+         return mouseProtocol;
+     }
+     /**
+      * Draw the left and right cells of a two-cell-wide (full-width) glyph.
+      *
+      * @param leftX the x position to draw the left half to
+      * @param leftY the y position to draw the left half to
+      * @param rightX the x position to draw the right half to
+      * @param rightY the y position to draw the right half to
+      * @param ch the character to draw
+      */
+     private void drawHalves(final int leftX, final int leftY,
+         final int rightX, final int rightY, final int ch) {
+         // System.err.println("drawHalves(): " + Integer.toHexString(ch));
+         if (lastTextHeight != textHeight) {
+             glyphMaker = GlyphMaker.getInstance(textHeight);
+             lastTextHeight = textHeight;
+         }
+         Cell cell = new Cell(ch, currentState.attr);
+         BufferedImage image = glyphMaker.getImage(cell, textWidth * 2,
+             textHeight);
+         BufferedImage leftImage = image.getSubimage(0, 0, textWidth,
+             textHeight);
+         BufferedImage rightImage = image.getSubimage(textWidth, 0, textWidth,
+             textHeight);
+         Cell left = new Cell(cell);
+         left.setImage(leftImage);
+         left.setWidth(Cell.Width.LEFT);
+         display.get(leftY).replace(leftX, left);
+         Cell right = new Cell(cell);
+         right.setImage(rightImage);
+         right.setWidth(Cell.Width.RIGHT);
+         display.get(rightY).replace(rightX, right);
+     }
+     /**
+      * Set the width of a character cell in pixels.
+      *
+      * @param textWidth the width in pixels of a character cell
+      */
+     public void setTextWidth(final int textWidth) {
+         this.textWidth = textWidth;
+     }
+     /**
+      * Set the height of a character cell in pixels.
+      *
+      * @param textHeight the height in pixels of a character cell
+      */
+     public void setTextHeight(final int textHeight) {
+         this.textHeight = textHeight;
+     }
+     /**
+      * Parse a sixel string into a bitmap image, and overlay that image onto
+      * the text cells.
+      */
+     private void parseSixel() {
+         /*
+         System.err.println("parseSixel(): '" + sixelParseBuffer.toString()
+             + "'");
+         */
+         Sixel sixel = new Sixel(sixelParseBuffer.toString(), sixelPalette);
+         BufferedImage image = sixel.getImage();
+         // System.err.println("parseSixel(): image " + image);
+         if (image == null) {
+             // Sixel data was malformed in some way, bail out.
+             return;
+         }
+         /*
+          * Procedure:
+          *
+          * Break up the image into text cell sized pieces as a new array of
+          * Cells.
+          *
+          * Note original column position x0.
+          *
+          * For each cell:
+          *
+          * 1. Advance (printCharacter(' ')) for horizontal increment, or
+          *    index (linefeed() + cursorPosition(y, x0)) for vertical
+          *    increment.
+          *
+          * 2. Set (x, y) cell image data.
+          *
+          * 3. For the right and bottom edges:
+          *
+          *   a. Render the text to pixels using Terminus font.
+          *
+          *   b. Blit the image on top of the text, using alpha channel.
+          */
+         int cellColumns = image.getWidth() / textWidth;
+         if (cellColumns * textWidth < image.getWidth()) {
+             cellColumns++;
+         }
+         int cellRows = image.getHeight() / textHeight;
+         if (cellRows * textHeight < image.getHeight()) {
+             cellRows++;
+         }
+         // Break the image up into an array of cells.
+         Cell [][] cells = new Cell[cellColumns][cellRows];
+         for (int x = 0; x < cellColumns; x++) {
+             for (int y = 0; y < cellRows; y++) {
+                 int width = textWidth;
+                 if ((x + 1) * textWidth > image.getWidth()) {
+                     width = image.getWidth() - (x * textWidth);
+                 }
+                 int height = textHeight;
+                 if ((y + 1) * textHeight > image.getHeight()) {
+                     height = image.getHeight() - (y * textHeight);
+                 }
+                 Cell cell = new Cell();
+                 cell.setImage(image.getSubimage(x * textWidth,
+                         y * textHeight, width, height));
+                 cells[x][y] = cell;
+             }
+         }
+         int x0 = currentState.cursorX;
+         for (int y = 0; y < cellRows; y++) {
+             for (int x = 0; x < cellColumns; x++) {
+                 assert (currentState.cursorX <= rightMargin);
+                 // TODO: Render text of current cell first, then image over
+                 // it (accounting for blank pixels).  For now, just copy the
+                 // cell.
+                 DisplayLine line = display.get(currentState.cursorY);
+                 line.replace(currentState.cursorX, cells[x][y]);
+                 // If at the end of the visible screen, stop.
+                 if (currentState.cursorX == rightMargin) {
+                     break;
+                 }
+                 // Room for more image on the visible screen.
+                 currentState.cursorX++;
+             }
+             linefeed();
+             cursorPosition(currentState.cursorY, x0);
+         }
+     }
+     /**
+      * Parse a "Jexer" image string into a bitmap image, and overlay that
+      * image onto the text cells.
+      *
+      * @param pw width token
+      * @param ph height token
+      * @param ps scroll token
+      * @param data pixel data
+      */
+     private void parseJexerImage(final String pw, final String ph,
+         final String ps, final String data) {
+         int imageWidth = 0;
+         int imageHeight = 0;
+         boolean scroll = false;
+         try {
+             imageWidth = Integer.parseInt(pw);
+             imageHeight = Integer.parseInt(ph);
+         } catch (NumberFormatException e) {
+             // SQUASH
+             return;
+         }
+         if ((imageWidth < 1)
+             || (imageWidth > 10000)
+             || (imageHeight < 1)
+             || (imageHeight > 10000)
+         ) {
+             return;
+         }
+         if (ps.equals("1")) {
+             scroll = true;
+         } else if (ps.equals("0")) {
+             scroll = false;
+         } else {
+             return;
+         }
+         java.util.Base64.Decoder base64 = java.util.Base64.getDecoder();
+         byte [] bytes = base64.decode(data);
+         if (bytes.length != (imageWidth * imageHeight * 3)) {
+             return;
+         }
+         BufferedImage image = new BufferedImage(imageWidth, imageHeight,
+             BufferedImage.TYPE_INT_ARGB);
+         for (int x = 0; x < imageWidth; x++) {
+             for (int y = 0; y < imageHeight; y++) {
+                 int red   = bytes[(y * imageWidth * 3) + (x * 3)    ];
+                 if (red < 0) {
+                     red += 256;
+                 }
+                 int green = bytes[(y * imageWidth * 3) + (x * 3) + 1];
+                 if (green < 0) {
+                     green += 256;
+                 }
+                 int blue  = bytes[(y * imageWidth * 3) + (x * 3) + 2];
+                 if (blue < 0) {
+                     blue += 256;
+                 }
+                 int rgb = 0xFF000000 | (red << 16) | (green << 8) | blue;
+                 image.setRGB(x, y, rgb);
+             }
+         }
+         /*
+          * Procedure:
+          *
+          * Break up the image into text cell sized pieces as a new array of
+          * Cells.
+          *
+          * Note original column position x0.
+          *
+          * For each cell:
+          *
+          * 1. Advance (printCharacter(' ')) for horizontal increment, or
+          *    index (linefeed() + cursorPosition(y, x0)) for vertical
+          *    increment.
+          *
+          * 2. Set (x, y) cell image data.
+          *
+          * 3. For the right and bottom edges:
+          *
+          *   a. Render the text to pixels using Terminus font.
+          *
+          *   b. Blit the image on top of the text, using alpha channel.
+          */
+         int cellColumns = image.getWidth() / textWidth;
+         if (cellColumns * textWidth < image.getWidth()) {
+             cellColumns++;
+         }
+         int cellRows = image.getHeight() / textHeight;
+         if (cellRows * textHeight < image.getHeight()) {
+             cellRows++;
+         }
+         // Break the image up into an array of cells.
+         Cell [][] cells = new Cell[cellColumns][cellRows];
+         for (int x = 0; x < cellColumns; x++) {
+             for (int y = 0; y < cellRows; y++) {
+                 int width = textWidth;
+                 if ((x + 1) * textWidth > image.getWidth()) {
+                     width = image.getWidth() - (x * textWidth);
+                 }
+                 int height = textHeight;
+                 if ((y + 1) * textHeight > image.getHeight()) {
+                     height = image.getHeight() - (y * textHeight);
+                 }
+                 Cell cell = new Cell();
+                 cell.setImage(image.getSubimage(x * textWidth,
+                         y * textHeight, width, height));
+                 cells[x][y] = cell;
+             }
+         }
+         int x0 = currentState.cursorX;
+         for (int y = 0; y < cellRows; y++) {
+             for (int x = 0; x < cellColumns; x++) {
+                 assert (currentState.cursorX <= rightMargin);
+                 DisplayLine line = display.get(currentState.cursorY);
+                 line.replace(currentState.cursorX, cells[x][y]);
+                 // If at the end of the visible screen, stop.
+                 if (currentState.cursorX == rightMargin) {
+                     break;
+                 }
+                 // Room for more image on the visible screen.
+                 currentState.cursorX++;
+             }
+             if ((scroll == true)
+                 || ((scroll == false)
+                     && (currentState.cursorY < scrollRegionBottom))
+             ) {
+                 linefeed();
+             }
+             cursorPosition(currentState.cursorY, x0);
+         }
+     }
+ }
index 0000000000000000000000000000000000000000,a4c00fc67f1da1ca38847b9bb5dad63daac5e006..a4c00fc67f1da1ca38847b9bb5dad63daac5e006
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,589 +1,589 @@@
+ /*
+  * Jexer - Java Text User Interface
+  *
+  * The MIT License (MIT)
+  *
+  * 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"),
+  * to deal in the Software without restriction, including without limitation
+  * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+  * and/or sell copies of the Software, and to permit persons to whom the
+  * Software is furnished to do so, subject to the following conditions:
+  *
+  * The above copyright notice and this permission notice shall be included in
+  * all copies or substantial portions of the Software.
+  *
+  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+  * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+  * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+  * DEALINGS IN THE SOFTWARE.
+  *
+  * @author Kevin Lamonte [kevin.lamonte@gmail.com]
+  * @version 1
+  */
+ package jexer.tterminal;
+ import java.awt.Color;
+ import java.awt.Graphics2D;
+ import java.awt.image.BufferedImage;
+ import java.util.ArrayList;
+ import java.util.HashMap;
+ /**
+  * Sixel parses a buffer of sixel image data into a BufferedImage.
+  */
+ public class Sixel {
+     // ------------------------------------------------------------------------
+     // Constants --------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Parser character scan states.
+      */
+     private enum ScanState {
+         GROUND,
+         RASTER,
+         COLOR,
+         REPEAT,
+     }
+     // ------------------------------------------------------------------------
+     // Variables --------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * If true, enable debug messages.
+      */
+     private static boolean DEBUG = false;
+     /**
+      * Number of pixels to increment when we need more horizontal room.
+      */
+     private static int WIDTH_INCREASE = 400;
+     /**
+      * Number of pixels to increment when we need more vertical room.
+      */
+     private static int HEIGHT_INCREASE = 400;
+     /**
+      * Maximum width in pixels.
+      */
+     private static int MAX_WIDTH = 1000;
+     /**
+      * Maximum height in pixels.
+      */
+     private static int MAX_HEIGHT = 1000;
+     /**
+      * Current scanning state.
+      */
+     private ScanState scanState = ScanState.GROUND;
+     /**
+      * Parameters being collected.
+      */
+     private int [] params = new int[5];
+     /**
+      * Current parameter being collected.
+      */
+     private int paramsI = 0;
+     /**
+      * The sixel palette colors specified.
+      */
+     private HashMap<Integer, Color> palette;
+     /**
+      * The buffer to parse.
+      */
+     private String buffer;
+     /**
+      * The image being drawn to.
+      */
+     private BufferedImage image;
+     /**
+      * The real width of image.
+      */
+     private int width = 0;
+     /**
+      * The real height of image.
+      */
+     private int height = 0;
+     /**
+      * The width of image provided in the raster attribute.
+      */
+     private int rasterWidth = 0;
+     /**
+      * The height of image provided in the raster attribute.
+      */
+     private int rasterHeight = 0;
+     /**
+      * The repeat count.
+      */
+     private int repeatCount = -1;
+     /**
+      * The current drawing x position.
+      */
+     private int x = 0;
+     /**
+      * The maximum y drawn to.  This will set the final image height.
+      */
+     private int y = 0;
+     /**
+      * The current drawing color.
+      */
+     private Color color = Color.BLACK;
+     /**
+      * If set, abort processing this image.
+      */
+     private boolean abort = false;
+     // ------------------------------------------------------------------------
+     // Constructors -----------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Public constructor.
+      *
+      * @param buffer the sixel data to parse
+      * @param palette palette to use, or null for a private palette
+      */
+     public Sixel(final String buffer, final HashMap<Integer, Color> palette) {
+         this.buffer = buffer;
+         if (palette == null) {
+             this.palette = new HashMap<Integer, Color>();
+         } else {
+             this.palette = palette;
+         }
+     }
+     // ------------------------------------------------------------------------
+     // Sixel ------------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Get the image.
+      *
+      * @return the sixel data as an image.
+      */
+     public BufferedImage getImage() {
+         if (buffer != null) {
+             for (int i = 0; (i < buffer.length()) && (abort == false); i++) {
+                 consume(buffer.charAt(i));
+             }
+             buffer = null;
+         }
+         if (abort == true) {
+             return null;
+         }
+         if ((width > 0) && (height > 0) && (image != null)) {
+             /*
+             System.err.println(String.format("%d %d %d %d", width, y + 1,
+                     rasterWidth, rasterHeight));
+             */
+             if ((rasterWidth > width) || (rasterHeight > y + 1)) {
+                 resizeImage(Math.max(width, rasterWidth),
+                     Math.max(y + 1, rasterHeight));
+             }
+             return image.getSubimage(0, 0, width, y + 1);
+         }
+         return null;
+     }
+     /**
+      * Resize image to a new size.
+      *
+      * @param newWidth new width of image
+      * @param newHeight new height of image
+      */
+     private void resizeImage(final int newWidth, final int newHeight) {
+         BufferedImage newImage = new BufferedImage(newWidth, newHeight,
+             BufferedImage.TYPE_INT_ARGB);
+         if (image == null) {
+             image = newImage;
+             return;
+         }
+         if (DEBUG) {
+             System.err.println("resizeImage(); old " + image.getWidth() + "x" +
+                 image.getHeight() + " new " + newWidth + "x" + newHeight);
+         }
+         Graphics2D gr = newImage.createGraphics();
+         gr.drawImage(image, 0, 0, image.getWidth(), image.getHeight(), null);
+         gr.dispose();
+         image = newImage;
+     }
+     /**
+      * Clear the parameters and flags.
+      */
+     private void toGround() {
+         paramsI = 0;
+         for (int i = 0; i < params.length; i++) {
+             params[i] = 0;
+         }
+         scanState = ScanState.GROUND;
+         repeatCount = -1;
+     }
+     /**
+      * Get a color parameter value, with a default.
+      *
+      * @param position parameter index.  0 is the first parameter.
+      * @param defaultValue value to use if colorParams[position] doesn't exist
+      * @return parameter value
+      */
+     private int getParam(final int position, final int defaultValue) {
+         if (position > paramsI) {
+             return defaultValue;
+         }
+         return params[position];
+     }
+     /**
+      * Get a color parameter value, clamped to within min/max.
+      *
+      * @param position parameter index.  0 is the first parameter.
+      * @param defaultValue value to use if colorParams[position] doesn't exist
+      * @param minValue minimum value inclusive
+      * @param maxValue maximum value inclusive
+      * @return parameter value
+      */
+     private int getParam(final int position, final int defaultValue,
+         final int minValue, final int maxValue) {
+         assert (minValue <= maxValue);
+         int value = getParam(position, defaultValue);
+         if (value < minValue) {
+             value = minValue;
+         }
+         if (value > maxValue) {
+             value = maxValue;
+         }
+         return value;
+     }
+     /**
+      * Add sixel data to the image.
+      *
+      * @param ch the character of sixel data
+      */
+     private void addSixel(final char ch) {
+         int n = ((int) ch - 63);
+         if (DEBUG && (color == null)) {
+             System.err.println("color is null?!");
+             System.err.println(buffer);
+         }
+         int rgb = color.getRGB();
+         int rep = (repeatCount == -1 ? 1 : repeatCount);
+         if (DEBUG) {
+             System.err.println("addSixel() rep " + rep + " char " +
+                 Integer.toHexString(n) + " color " + color);
+         }
+         assert (n >= 0);
+         if (image == null) {
+             // The raster attributes was not provided.
+             resizeImage(WIDTH_INCREASE, HEIGHT_INCREASE);
+         }
+         if (x + rep > image.getWidth()) {
+             // Resize the image, give us another max(rep, WIDTH_INCREASE)
+             // pixels of horizontal length.
+             resizeImage(image.getWidth() + Math.max(rep, WIDTH_INCREASE),
+                 image.getHeight());
+         }
+         // If nothing will be drawn, just advance x.
+         if (n == 0) {
+             x += rep;
+             if (x > width) {
+                 width = x;
+             }
+             if (width > MAX_WIDTH) {
+                 abort = true;
+             }
+             return;
+         }
+         int dy = 0;
+         for (int i = 0; i < rep; i++) {
+             if ((n & 0x01) != 0) {
+                 dy = 0;
+                 image.setRGB(x, height + dy, rgb);
+             }
+             if ((n & 0x02) != 0) {
+                 dy = 1;
+                 image.setRGB(x, height + dy, rgb);
+             }
+             if ((n & 0x04) != 0) {
+                 dy = 2;
+                 image.setRGB(x, height + dy, rgb);
+             }
+             if ((n & 0x08) != 0) {
+                 dy = 3;
+                 image.setRGB(x, height + dy, rgb);
+             }
+             if ((n & 0x10) != 0) {
+                 dy = 4;
+                 image.setRGB(x, height + dy, rgb);
+             }
+             if ((n & 0x20) != 0) {
+                 dy = 5;
+                 image.setRGB(x, height + dy, rgb);
+             }
+             if (height + dy > y) {
+                 y = height + dy;
+             }
+             x++;
+         }
+         if (x > width) {
+             width = x;
+         }
+         if (width > MAX_WIDTH) {
+             abort = true;
+         }
+         if (y + 1 > MAX_HEIGHT) {
+             abort = true;
+         }
+     }
+     /**
+      * Process a color palette change.
+      */
+     private void setPalette() {
+         int idx = getParam(0, 0);
+         if (paramsI == 0) {
+             Color newColor = palette.get(idx);
+             if (newColor != null) {
+                 color = newColor;
+             } else {
+                 if (DEBUG) {
+                     System.err.println("COLOR " + idx + " NOT FOUND");
+                 }
+                 color = Color.BLACK;
+             }
+             if (DEBUG) {
+                 System.err.println("set color " + idx + " " + color);
+             }
+             return;
+         }
+         int type = getParam(1, 0);
+         float red   = (float) (getParam(2, 0, 0, 100) / 100.0);
+         float green = (float) (getParam(3, 0, 0, 100) / 100.0);
+         float blue  = (float) (getParam(4, 0, 0, 100) / 100.0);
+         if (type == 2) {
+             Color newColor = new Color(red, green, blue);
+             palette.put(idx, newColor);
+             if (DEBUG) {
+                 System.err.println("Palette color " + idx + " --> " + newColor);
+             }
+         } else {
+             if (DEBUG) {
+                 System.err.println("UNKNOWN COLOR TYPE " + type + ": " + type +
+                     " " + idx + " R " + red + " G " + green + " B " + blue);
+             }
+         }
+     }
+     /**
+      * Parse the raster attributes.
+      */
+     private void parseRaster() {
+         int pan = getParam(0, 0);  // Aspect ratio numerator
+         int pad = getParam(1, 0);  // Aspect ratio denominator
+         int pah = getParam(2, 0);  // Horizontal width
+         int pav = getParam(3, 0);  // Vertical height
+         if ((pan == pad) && (pah > 0) && (pav > 0)) {
+             rasterWidth = pah;
+             rasterHeight = pav;
+             if ((rasterWidth <= MAX_WIDTH) && (rasterHeight <= MAX_HEIGHT)) {
+                 resizeImage(rasterWidth, rasterHeight);
+             } else {
+                 abort = true;
+             }
+         } else {
+             abort = true;
+         }
+     }
+     /**
+      * Run this input character through the sixel state machine.
+      *
+      * @param ch character from the remote side
+      */
+     private void consume(char ch) {
+         // DEBUG
+         // System.err.printf("Sixel.consume() %c STATE = %s\n", ch, scanState);
+         // Between decimal 63 (inclusive) and 127 (exclusive) --> pixels
+         if ((ch >= 63) && (ch < 127)) {
+             if (scanState == ScanState.COLOR) {
+                 setPalette();
+             }
+             if (scanState == ScanState.RASTER) {
+                 parseRaster();
+                 toGround();
+             }
+             addSixel(ch);
+             toGround();
+             return;
+         }
+         if (ch == '#') {
+             // Next color is here, parse what we had before.
+             if (scanState == ScanState.COLOR) {
+                 setPalette();
+                 toGround();
+             }
+             if (scanState == ScanState.RASTER) {
+                 parseRaster();
+                 toGround();
+             }
+             scanState = ScanState.COLOR;
+             return;
+         }
+         if (ch == '!') {
+             // Repeat count
+             if (scanState == ScanState.COLOR) {
+                 setPalette();
+                 toGround();
+             }
+             if (scanState == ScanState.RASTER) {
+                 parseRaster();
+                 toGround();
+             }
+             scanState = ScanState.REPEAT;
+             repeatCount = 0;
+             return;
+         }
+         if (ch == '-') {
+             if (scanState == ScanState.COLOR) {
+                 setPalette();
+                 toGround();
+             }
+             if (scanState == ScanState.RASTER) {
+                 parseRaster();
+                 toGround();
+             }
+             height += 6;
+             x = 0;
+             if (height + 6 > image.getHeight()) {
+                 // Resize the image, give us another HEIGHT_INCREASE
+                 // pixels of vertical length.
+                 resizeImage(image.getWidth(),
+                     image.getHeight() + HEIGHT_INCREASE);
+             }
+             return;
+         }
+         if (ch == '$') {
+             if (scanState == ScanState.COLOR) {
+                 setPalette();
+                 toGround();
+             }
+             if (scanState == ScanState.RASTER) {
+                 parseRaster();
+                 toGround();
+             }
+             x = 0;
+             return;
+         }
+         if (ch == '"') {
+             if (scanState == ScanState.COLOR) {
+                 setPalette();
+                 toGround();
+             }
+             scanState = ScanState.RASTER;
+             return;
+         }
+         switch (scanState) {
+         case GROUND:
+             // Unknown character.
+             if (DEBUG) {
+                 System.err.println("UNKNOWN CHAR: " + ch);
+             }
+             return;
+         case RASTER:
+             // 30-39, 3B --> param
+             if ((ch >= '0') && (ch <= '9')) {
+                 params[paramsI] *= 10;
+                 params[paramsI] += (ch - '0');
+             }
+             if (ch == ';') {
+                 if (paramsI < params.length - 1) {
+                     paramsI++;
+                 }
+             }
+             return;
+         case COLOR:
+             // 30-39, 3B --> param
+             if ((ch >= '0') && (ch <= '9')) {
+                 params[paramsI] *= 10;
+                 params[paramsI] += (ch - '0');
+             }
+             if (ch == ';') {
+                 if (paramsI < params.length - 1) {
+                     paramsI++;
+                 }
+             }
+             return;
+         case REPEAT:
+             if ((ch >= '0') && (ch <= '9')) {
+                 if (repeatCount == -1) {
+                     repeatCount = (int) (ch - '0');
+                 } else {
+                     repeatCount *= 10;
+                     repeatCount += (int) (ch - '0');
+                 }
+             }
+             return;
+         }
+     }
+ }
index 0000000000000000000000000000000000000000,b92d1535dfd4e82f2c79d59805af56ceda6ec373..b92d1535dfd4e82f2c79d59805af56ceda6ec373
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,33 +1,33 @@@
+ /*
+  * Jexer - Java Text User Interface
+  *
+  * The MIT License (MIT)
+  *
+  * 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"),
+  * to deal in the Software without restriction, including without limitation
+  * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+  * and/or sell copies of the Software, and to permit persons to whom the
+  * Software is furnished to do so, subject to the following conditions:
+  *
+  * The above copyright notice and this permission notice shall be included in
+  * all copies or substantial portions of the Software.
+  *
+  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+  * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+  * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+  * DEALINGS IN THE SOFTWARE.
+  *
+  * @author Kevin Lamonte [kevin.lamonte@gmail.com]
+  * @version 1
+  */
+ /**
+  * An ECMA-48 / ANSI X3.64 style terminal emulator.
+  */
+ package jexer.tterminal;
index 0000000000000000000000000000000000000000,9bdec01dffd3cf51aeb5a233768b03e2f2b4e8e4..9bdec01dffd3cf51aeb5a233768b03e2f2b4e8e4
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,211 +1,211 @@@
+ /*
+  * Jexer - Java Text User Interface
+  *
+  * The MIT License (MIT)
+  *
+  * 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"),
+  * to deal in the Software without restriction, including without limitation
+  * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+  * and/or sell copies of the Software, and to permit persons to whom the
+  * Software is furnished to do so, subject to the following conditions:
+  *
+  * The above copyright notice and this permission notice shall be included in
+  * all copies or substantial portions of the Software.
+  *
+  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+  * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+  * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+  * DEALINGS IN THE SOFTWARE.
+  *
+  * @author Kevin Lamonte [kevin.lamonte@gmail.com]
+  * @version 1
+  */
+ package jexer.ttree;
+ import java.io.File;
+ import java.io.IOException;
+ import java.util.Collections;
+ import java.util.List;
+ import java.util.LinkedList;
+ import jexer.TWidget;
+ /**
+  * TDirectoryTreeItem is a single item in a disk directory tree view.
+  */
+ public class TDirectoryTreeItem extends TTreeItem {
+     // ------------------------------------------------------------------------
+     // Variables --------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * File corresponding to this list item.
+      */
+     private File file;
+     /**
+      * The TTreeViewWidget containing this directory tree.
+      */
+     private TTreeViewWidget treeViewWidget;
+     // ------------------------------------------------------------------------
+     // Constructors -----------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Public constructor.
+      *
+      * @param view root TTreeViewWidget
+      * @param text text for this item
+      * @param expanded if true, have it expanded immediately
+      * @throws IOException if a java.io operation throws
+      */
+     public TDirectoryTreeItem(final TTreeViewWidget view, final String text,
+         final boolean expanded) throws IOException {
+         this(view, text, expanded, true);
+     }
+     /**
+      * Public constructor.
+      *
+      * @param view root TTreeViewWidget
+      * @param text text for this item
+      * @param expanded if true, have it expanded immediately
+      * @param openParents if true, expand all paths up the root path and
+      * return the root path entry
+      * @throws IOException if a java.io operation throws
+      */
+     public TDirectoryTreeItem(final TTreeViewWidget view, final String text,
+         final boolean expanded, final boolean openParents) throws IOException {
+         super(view.getTreeView(), text, false);
+         this.treeViewWidget = view;
+         List<String> parentFiles = new LinkedList<String>();
+         boolean oldExpanded = expanded;
+         // Convert to canonical path
+         File rootFile = new File(text);
+         rootFile = rootFile.getCanonicalFile();
+         if (openParents) {
+             setExpanded(true);
+             // Go up the directory tree
+             File parent = rootFile.getParentFile();
+             while (parent != null) {
+                 parentFiles.add(rootFile.getName());
+                 rootFile = rootFile.getParentFile();
+                 parent = rootFile.getParentFile();
+             }
+         }
+         file = rootFile;
+         if (rootFile.getParentFile() == null) {
+             // This is a filesystem root, use its full name
+             setText(rootFile.getCanonicalPath());
+         } else {
+             // This is a relative path.  We got here because openParents was
+             // false.
+             assert (!openParents);
+             setText(rootFile.getName());
+         }
+         onExpand();
+         if (openParents) {
+             TDirectoryTreeItem childFile = this;
+             Collections.reverse(parentFiles);
+             for (String p: parentFiles) {
+                 for (TWidget widget: childFile.getChildren()) {
+                     TDirectoryTreeItem child = (TDirectoryTreeItem) widget;
+                     if (child.getText().equals(p)) {
+                         childFile = child;
+                         childFile.setExpanded(true);
+                         childFile.onExpand();
+                         break;
+                     }
+                 }
+             }
+             unselect();
+             getTreeView().setSelected(childFile, true);
+             setExpanded(oldExpanded);
+         }
+         view.reflowData();
+     }
+     // ------------------------------------------------------------------------
+     // TTreeItem --------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Get the File corresponding to this list item.
+      *
+      * @return the File
+      */
+     public final File getFile() {
+         return file;
+     }
+     /**
+      * Called when this item is expanded or collapsed.  this.expanded will be
+      * true if this item was just expanded from a mouse click or keypress.
+      */
+     @Override
+     public final void onExpand() {
+         // System.err.printf("onExpand() %s\n", file);
+         if (file == null) {
+             return;
+         }
+         getChildren().clear();
+         // Make sure we can read it before trying to.
+         if (file.canRead()) {
+             setSelectable(true);
+         } else {
+             setSelectable(false);
+         }
+         assert (file.isDirectory());
+         setExpandable(true);
+         if (!isExpanded() || !isExpandable()) {
+             return;
+         }
+         File [] listFiles = file.listFiles();
+         if (listFiles != null) {
+             for (File f: listFiles) {
+                 // System.err.printf("   -> file %s %s\n", file, file.getName());
+                 if (f.getName().startsWith(".")) {
+                     // Hide dot-files
+                     continue;
+                 }
+                 if (!f.isDirectory()) {
+                     continue;
+                 }
+                 try {
+                     TDirectoryTreeItem item = new TDirectoryTreeItem(treeViewWidget,
+                         f.getCanonicalPath(), false, false);
+                     item.level = this.level + 1;
+                     getChildren().add(item);
+                 } catch (IOException e) {
+                     continue;
+                 }
+             }
+         }
+         Collections.sort(getChildren());
+     }
+ }
index 0000000000000000000000000000000000000000,44c408b2bd3f6095bd8a27d758d76b0b233e3964..44c408b2bd3f6095bd8a27d758d76b0b233e3964
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,483 +1,483 @@@
+ /*
+  * Jexer - Java Text User Interface
+  *
+  * The MIT License (MIT)
+  *
+  * 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"),
+  * to deal in the Software without restriction, including without limitation
+  * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+  * and/or sell copies of the Software, and to permit persons to whom the
+  * Software is furnished to do so, subject to the following conditions:
+  *
+  * The above copyright notice and this permission notice shall be included in
+  * all copies or substantial portions of the Software.
+  *
+  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+  * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+  * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+  * DEALINGS IN THE SOFTWARE.
+  *
+  * @author Kevin Lamonte [kevin.lamonte@gmail.com]
+  * @version 1
+  */
+ package jexer.ttree;
+ import java.util.ArrayList;
+ import java.util.List;
+ import jexer.TWidget;
+ import jexer.bits.CellAttributes;
+ import jexer.bits.GraphicsChars;
+ import jexer.bits.StringUtils;
+ import jexer.event.TKeypressEvent;
+ import jexer.event.TMouseEvent;
+ import static jexer.TKeypress.*;
+ /**
+  * TTreeItem is a single item in a tree view.
+  */
+ public class TTreeItem extends TWidget {
+     // ------------------------------------------------------------------------
+     // Variables --------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Hang onto reference to my parent TTreeView so I can call its reflow()
+      * when I add a child node.
+      */
+     private TTreeView view;
+     /**
+      * Displayable text for this item.
+      */
+     private String text;
+     /**
+      * If true, this item is expanded in the tree view.
+      */
+     private boolean expanded = true;
+     /**
+      * If true, this item can be expanded in the tree view.
+      */
+     private boolean expandable = false;
+     /**
+      * The vertical bars and such along the left side.
+      */
+     private String prefix = "";
+     /**
+      * Tree level.
+      */
+     protected int level = 0;
+     /**
+      * True means selected.
+      */
+     private boolean selected = false;
+     /**
+      * True means select-able.
+      */
+     private boolean selectable = true;
+     /**
+      * Whether or not this item is last in its parent's list of children.
+      */
+     private boolean last = false;
+     /**
+      * Pointer to the previous keyboard-navigable item (kbUp).  Note package
+      * private access.
+      */
+     TTreeItem keyboardPrevious = null;
+     /**
+      * Pointer to the next keyboard-navigable item (kbDown).  Note package
+      * private access.
+      */
+     TTreeItem keyboardNext = null;
+     // ------------------------------------------------------------------------
+     // Constructors -----------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Public constructor.
+      *
+      * @param view root TTreeView
+      * @param text text for this item
+      * @param expanded if true, have it expanded immediately
+      */
+     public TTreeItem(final TTreeView view, final String text,
+         final boolean expanded) {
+         super(view, 0, 0, view.getWidth() - 3, 1);
+         this.text = text;
+         this.expanded = expanded;
+         this.view = view;
+         if (view.getTreeRoot() == null) {
+             view.setTreeRoot(this);
+         } else {
+             view.alignTree();
+         }
+     }
+     // ------------------------------------------------------------------------
+     // Event handlers ---------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Handle mouse release events.
+      *
+      * @param mouse mouse button release event
+      */
+     @Override
+     public void onMouseUp(final TMouseEvent mouse) {
+         if ((mouse.getX() == (getExpanderX() - view.getLeftColumn()))
+             && (mouse.getY() == 0)
+         ) {
+             if (level == 0) {
+                 // Root node can't switch.
+                 return;
+             }
+             if (selectable) {
+                 // Flip expanded flag
+                 expanded = !expanded;
+                 if (expanded == false) {
+                     // Unselect children that became invisible
+                     unselect();
+                 }
+                 view.setSelected(this, false);
+             }
+             // Let subclasses do something with this
+             onExpand();
+             // Update the screen after any thing has expanded/contracted
+             view.alignTree();
+         } else if (mouse.getY() == 0) {
+             // Do the action associated with this item.
+             view.setSelected(this, false);
+             view.dispatch();
+         }
+     }
+     /**
+      * Called when this item is expanded or collapsed.  this.expanded will be
+      * true if this item was just expanded from a mouse click or keypress.
+      */
+     public void onExpand() {
+         // Default: do nothing.
+         if (!expandable) {
+             return;
+         }
+     }
+     /**
+      * Handle keystrokes.
+      *
+      * @param keypress keystroke event
+      */
+     @Override
+     public void onKeypress(final TKeypressEvent keypress) {
+         if (keypress.equals(kbLeft)
+             || keypress.equals(kbRight)
+             || keypress.equals(kbSpace)
+         ) {
+             if (level == 0) {
+                 // Root node can't switch.
+                 return;
+             }
+             if (selectable) {
+                 // Flip expanded flag
+                 expanded = !expanded;
+                 if (expanded == false) {
+                     // Unselect children that became invisible
+                     unselect();
+                 }
+                 view.setSelected(this, false);
+             }
+             // Let subclasses do something with this
+             onExpand();
+         } else if (keypress.equals(kbEnter)) {
+             // Do the action associated with this item.
+             view.dispatch();
+         } else {
+             // Pass other keys (tab etc.) on to TWidget's handler.
+             super.onKeypress(keypress);
+         }
+     }
+     // ------------------------------------------------------------------------
+     // TWidget ----------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Draw this item to a window.
+      */
+     @Override
+     public void draw() {
+         if ((getY() < 0) || (getY() > getParent().getHeight() - 1)) {
+             return;
+         }
+         int offset = -view.getLeftColumn();
+         CellAttributes color = getTheme().getColor("ttreeview");
+         CellAttributes textColor = getTheme().getColor("ttreeview");
+         CellAttributes expanderColor = getTheme().getColor("ttreeview.expandbutton");
+         CellAttributes selectedColor = getTheme().getColor("ttreeview.selected");
+         if (!getParent().isAbsoluteActive()) {
+             color = getTheme().getColor("ttreeview.inactive");
+             textColor = getTheme().getColor("ttreeview.inactive");
+             selectedColor = getTheme().getColor("ttreeview.selected.inactive");
+         }
+         if (!selectable) {
+             textColor = getTheme().getColor("ttreeview.unreadable");
+         }
+         // Blank out the background
+         hLineXY(0, 0, getWidth(), ' ', color);
+         String line = prefix;
+         if (level > 0) {
+             if (last) {
+                 line += GraphicsChars.CP437[0xC0];
+             } else {
+                 line += GraphicsChars.CP437[0xC3];
+             }
+             line += GraphicsChars.CP437[0xC4];
+             if (expandable) {
+                 line += "[ ] ";
+             } else {
+                 line += " ";
+             }
+         }
+         putStringXY(offset, 0, line, color);
+         if (selected) {
+             putStringXY(offset + StringUtils.width(line), 0, text, selectedColor);
+         } else {
+             putStringXY(offset + StringUtils.width(line), 0, text, textColor);
+         }
+         if ((level > 0) && (expandable)) {
+             if (expanded) {
+                 putCharXY(offset + getExpanderX(), 0, '-', expanderColor);
+             } else {
+                 putCharXY(offset + getExpanderX(), 0, '+', expanderColor);
+             }
+         }
+     }
+     // ------------------------------------------------------------------------
+     // TTreeItem --------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Get the parent TTreeView.
+      *
+      * @return the parent TTreeView
+      */
+     public final TTreeView getTreeView() {
+         return view;
+     }
+     /**
+      * Get the displayable text for this item.
+      *
+      * @return the displayable text for this item
+      */
+     public final String getText() {
+         return text;
+     }
+     /**
+      * Set the displayable text for this item.
+      *
+      * @param text the displayable text for this item
+      */
+     public final void setText(final String text) {
+         this.text = text;
+     }
+     /**
+      * Get expanded value.
+      *
+      * @return if true, this item is expanded
+      */
+     public final boolean isExpanded() {
+         return expanded;
+     }
+     /**
+      * Set expanded value.
+      *
+      * @param expanded new value
+      */
+     public final void setExpanded(final boolean expanded) {
+         if (level == 0) {
+             // Root node can't be unexpanded, ever.
+             this.expanded = true;
+             return;
+         }
+         if (level > 0) {
+             this.expanded = expanded;
+         }
+     }
+     /**
+      * Get expandable value.
+      *
+      * @return if true, this item is expandable
+      */
+     public final boolean isExpandable() {
+         return expandable;
+     }
+     /**
+      * Set expandable value.
+      *
+      * @param expandable new value
+      */
+     public final void setExpandable(final boolean expandable) {
+         if (level == 0) {
+             // Root node can't be unexpanded, ever.
+             this.expandable = true;
+             return;
+         }
+         if (level > 0) {
+             this.expandable = expandable;
+         }
+     }
+     /**
+      * Get the vertical bars and such along the left side.
+      *
+      * @return the vertical bars and such along the left side
+      */
+     public final String getPrefix() {
+         return prefix;
+     }
+     /**
+      * Get selected value.
+      *
+      * @return if true, this item is selected
+      */
+     public final boolean isSelected() {
+         return selected;
+     }
+     /**
+      * Set selected value.
+      *
+      * @param selected new value
+      */
+     public final void setSelected(final boolean selected) {
+         this.selected = selected;
+     }
+     /**
+      * Set selectable value.
+      *
+      * @param selectable new value
+      */
+     public final void setSelectable(final boolean selectable) {
+         this.selectable = selectable;
+     }
+     /**
+      * Get the length of the widest item to display.
+      *
+      * @return the maximum number of columns for this item or its children
+      */
+     public int getMaximumColumn() {
+         int max = prefix.length() + 4 + StringUtils.width(text);
+         for (TWidget widget: getChildren()) {
+             TTreeItem item = (TTreeItem) widget;
+             int n = item.prefix.length() + 4 + StringUtils.width(item.text);
+             if (n > max) {
+                 max = n;
+             }
+         }
+         return max;
+     }
+     /**
+      * Recursively expand the tree into a linear array of items.
+      *
+      * @param prefix vertical bar of parent levels and such that is set on
+      * each child
+      * @param last if true, this is the "last" leaf node of a tree
+      * @return additional items to add to the array
+      */
+     public List<TTreeItem> expandTree(final String prefix, final boolean last) {
+         List<TTreeItem> array = new ArrayList<TTreeItem>();
+         this.last = last;
+         this.prefix = prefix;
+         array.add(this);
+         if ((getChildren().size() == 0) || !expanded) {
+             return array;
+         }
+         String newPrefix = prefix;
+         if (level > 0) {
+             if (last) {
+                 newPrefix += "  ";
+             } else {
+                 newPrefix += GraphicsChars.CP437[0xB3];
+                 newPrefix += ' ';
+             }
+         }
+         for (int i = 0; i < getChildren().size(); i++) {
+             TTreeItem item = (TTreeItem) getChildren().get(i);
+             if (i == getChildren().size() - 1) {
+                 array.addAll(item.expandTree(newPrefix, true));
+             } else {
+                 array.addAll(item.expandTree(newPrefix, false));
+             }
+         }
+         return array;
+     }
+     /**
+      * Get the x spot for the + or - to expand/collapse.
+      *
+      * @return column of the expand/collapse button
+      */
+     private int getExpanderX() {
+         if ((level == 0) || (!expandable)) {
+             return 0;
+         }
+         return prefix.length() + 3;
+     }
+     /**
+      * Recursively unselect me and my children.
+      */
+     public void unselect() {
+         if (selected == true) {
+             selected = false;
+             view.setSelected(null, false);
+         }
+         for (TWidget widget: getChildren()) {
+             if (widget instanceof TTreeItem) {
+                 TTreeItem item = (TTreeItem) widget;
+                 item.unselect();
+             }
+         }
+     }
+ }
index 0000000000000000000000000000000000000000,22f72ca850527b35932c8d4e3798c826f65f7686..22f72ca850527b35932c8d4e3798c826f65f7686
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,329 +1,329 @@@
+ /*
+  * Jexer - Java Text User Interface
+  *
+  * The MIT License (MIT)
+  *
+  * 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"),
+  * to deal in the Software without restriction, including without limitation
+  * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+  * and/or sell copies of the Software, and to permit persons to whom the
+  * Software is furnished to do so, subject to the following conditions:
+  *
+  * The above copyright notice and this permission notice shall be included in
+  * all copies or substantial portions of the Software.
+  *
+  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+  * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+  * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+  * DEALINGS IN THE SOFTWARE.
+  *
+  * @author Kevin Lamonte [kevin.lamonte@gmail.com]
+  * @version 1
+  */
+ package jexer.ttree;
+ import jexer.TAction;
+ import jexer.TKeypress;
+ import jexer.TWidget;
+ import jexer.event.TKeypressEvent;
+ import static jexer.TKeypress.*;
+ /**
+  * TTreeView implements a simple tree view.
+  */
+ public class TTreeView extends TWidget {
+     // ------------------------------------------------------------------------
+     // Variables --------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Root of the tree.
+      */
+     private TTreeItem treeRoot;
+     /**
+      * Only one of my children can be selected.
+      */
+     private TTreeItem selectedItem = null;
+     /**
+      * The action to perform when the user selects an item.
+      */
+     private TAction action = null;
+     /**
+      * The top line currently visible.
+      */
+     private int topLine = 0;
+     /**
+      * The left column currently visible.
+      */
+     private int leftColumn = 0;
+     // ------------------------------------------------------------------------
+     // Constructors -----------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Public constructor.
+      *
+      * @param parent parent widget
+      * @param x column relative to parent
+      * @param y row relative to parent
+      * @param width width of tree view
+      * @param height height of tree view
+      */
+     public TTreeView(final TWidget parent, final int x, final int y,
+         final int width, final int height) {
+         this(parent, x, y, width, height, null);
+     }
+     /**
+      * Public constructor.
+      *
+      * @param parent parent widget
+      * @param x column relative to parent
+      * @param y row relative to parent
+      * @param width width of tree view
+      * @param height height of tree view
+      * @param action action to perform when an item is selected
+      */
+     public TTreeView(final TWidget parent, final int x, final int y,
+         final int width, final int height, final TAction action) {
+         super(parent, x, y, width, height);
+         this.action = action;
+     }
+     // ------------------------------------------------------------------------
+     // Event handlers ---------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Handle keystrokes.
+      *
+      * @param keypress keystroke event
+      */
+     @Override
+     public void onKeypress(final TKeypressEvent keypress) {
+         if (keypress.equals(kbUp)) {
+             // Select the previous item
+             if (selectedItem != null) {
+                 if (selectedItem.keyboardPrevious != null) {
+                     setSelected(selectedItem.keyboardPrevious, true);
+                 }
+             }
+         } else if (keypress.equals(kbDown)) {
+             // Select the next item
+             if (selectedItem != null) {
+                 if (selectedItem.keyboardNext != null) {
+                     setSelected(selectedItem.keyboardNext, true);
+                 }
+             }
+         } else if (keypress.equals(kbPgDn)) {
+             for (int i = 0; i < getHeight() - 1; i++) {
+                 onKeypress(new TKeypressEvent(TKeypress.kbDown));
+             }
+         } else if (keypress.equals(kbPgUp)) {
+             for (int i = 0; i < getHeight() - 1; i++) {
+                 onKeypress(new TKeypressEvent(TKeypress.kbUp));
+             }
+         } else if (keypress.equals(kbHome)) {
+             setSelected((TTreeItem) getChildren().get(0), false);
+             setTopLine(0);
+         } else if (keypress.equals(kbEnd)) {
+             setSelected((TTreeItem) getChildren().get(getChildren().size() - 1),
+                 true);
+         } else {
+             if (selectedItem != null) {
+                 selectedItem.onKeypress(keypress);
+             } else {
+                 // Pass other keys (tab etc.) on to TWidget's handler.
+                 super.onKeypress(keypress);
+             }
+         }
+     }
+     // ------------------------------------------------------------------------
+     // TWidget ----------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     // TTreeView --------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Get the root of the tree.
+      *
+      * @return the root of the tree
+      */
+     public final TTreeItem getTreeRoot() {
+         return treeRoot;
+     }
+     /**
+      * Set the root of the tree.
+      *
+      * @param treeRoot the new root of the tree
+      */
+     public final void setTreeRoot(final TTreeItem treeRoot) {
+         this.treeRoot = treeRoot;
+         alignTree();
+     }
+     /**
+      * Get the tree view item that was selected.
+      *
+      * @return the selected item, or null if no item is selected
+      */
+     public final TTreeItem getSelected() {
+         return selectedItem;
+     }
+     /**
+      * Set the new selected tree view item.
+      *
+      * @param item new item that became selected
+      * @param centerWindow if true, move the window to put the selected into
+      * view
+      */
+     public void setSelected(final TTreeItem item, final boolean centerWindow) {
+         if (item != null) {
+             item.setSelected(true);
+         }
+         if ((selectedItem != null) && (selectedItem != item)) {
+             selectedItem.setSelected(false);
+         }
+         selectedItem = item;
+         if (centerWindow) {
+             int y = 0;
+             for (TWidget widget: getChildren()) {
+                 if (widget == selectedItem) {
+                     break;
+                 }
+                 y++;
+             }
+             topLine = y - (getHeight() - 1)/2;
+             if (topLine > getChildren().size() - getHeight()) {
+                 topLine = getChildren().size() - getHeight();
+             }
+             if (topLine < 0) {
+                 topLine = 0;
+             }
+         }
+         if (selectedItem != null) {
+             activate(selectedItem);
+         }
+     }
+     /**
+      * Perform user selection action.
+      */
+     public void dispatch() {
+         if (action != null) {
+             action.DO(this);
+         }
+     }
+     /**
+      * Get the left column value.  0 is the leftmost column.
+      *
+      * @return the left column
+      */
+     public int getLeftColumn() {
+         return leftColumn;
+     }
+     /**
+      * Set the left column value.  0 is the leftmost column.
+      *
+      * @param leftColumn the new left column
+      */
+     public void setLeftColumn(final int leftColumn) {
+         this.leftColumn = leftColumn;
+     }
+     /**
+      * Get the top line (row) value.  0 is the topmost line.
+      *
+      * @return the top line
+      */
+     public int getTopLine() {
+         return topLine;
+     }
+     /**
+      * Set the top line value.  0 is the topmost line.
+      *
+      * @param topLine the new top line
+      */
+     public void setTopLine(final int topLine) {
+         this.topLine = topLine;
+     }
+     /**
+      * Get the total line (rows) count, based on the items that are visible
+      * and expanded.
+      *
+      * @return the line count
+      */
+     public int getTotalLineCount() {
+         if (treeRoot == null) {
+             return 0;
+         }
+         return getChildren().size();
+     }
+     /**
+      * Get the length of the widest item to display.
+      *
+      * @return the maximum number of columns for this item or its children
+      */
+     public int getMaximumColumn() {
+         if (treeRoot == null) {
+             return 0;
+         }
+         return treeRoot.getMaximumColumn();
+     }
+     /**
+      * Update the Y positions of all the children items to match the current
+      * topLine value.  Note package private access.
+      */
+     void alignTree() {
+         if (treeRoot == null) {
+             return;
+         }
+         // As we walk the list we also adjust next/previous pointers,
+         // resulting in a doubly-linked list but only of the expanded items.
+         TTreeItem p = null;
+         for (int i = 0; i < getChildren().size(); i++) {
+             TTreeItem item = (TTreeItem) getChildren().get(i);
+             if (p != null) {
+                 item.keyboardPrevious = p;
+                 p.keyboardNext = item;
+             }
+             p = item;
+             item.setY(i - topLine);
+             item.setWidth(getWidth());
+         }
+     }
+ }
index 0000000000000000000000000000000000000000,080a200497dfbe5389a8f62f3593688eb325fb72..080a200497dfbe5389a8f62f3593688eb325fb72
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,406 +1,406 @@@
+ /*
+  * Jexer - Java Text User Interface
+  *
+  * The MIT License (MIT)
+  *
+  * 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"),
+  * to deal in the Software without restriction, including without limitation
+  * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+  * and/or sell copies of the Software, and to permit persons to whom the
+  * Software is furnished to do so, subject to the following conditions:
+  *
+  * The above copyright notice and this permission notice shall be included in
+  * all copies or substantial portions of the Software.
+  *
+  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+  * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+  * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+  * DEALINGS IN THE SOFTWARE.
+  *
+  * @author Kevin Lamonte [kevin.lamonte@gmail.com]
+  * @version 1
+  */
+ package jexer.ttree;
+ import jexer.TAction;
+ import jexer.THScroller;
+ import jexer.TKeypress;
+ import jexer.TScrollableWidget;
+ import jexer.TVScroller;
+ import jexer.TWidget;
+ import jexer.bits.StringUtils;
+ import jexer.event.TKeypressEvent;
+ import jexer.event.TMouseEvent;
+ import jexer.event.TResizeEvent;
+ import static jexer.TKeypress.*;
+ /**
+  * TTreeViewWidget wraps a tree view with horizontal and vertical scrollbars.
+  */
+ public class TTreeViewWidget extends TScrollableWidget {
+     // ------------------------------------------------------------------------
+     // Variables --------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * The TTreeView
+      */
+     private TTreeView treeView;
+     /**
+      * If true, move the window to put the selected item in view.  This
+      * normally only happens once after setting treeRoot.
+      */
+     private boolean centerWindow = false;
+     /**
+      * Maximum width of a single line.
+      */
+     private int maxLineWidth;
+     // ------------------------------------------------------------------------
+     // Constructors -----------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Public constructor.
+      *
+      * @param parent parent widget
+      * @param x column relative to parent
+      * @param y row relative to parent
+      * @param width width of tree view
+      * @param height height of tree view
+      */
+     public TTreeViewWidget(final TWidget parent, final int x, final int y,
+         final int width, final int height) {
+         this(parent, x, y, width, height, null);
+     }
+     /**
+      * Public constructor.
+      *
+      * @param parent parent widget
+      * @param x column relative to parent
+      * @param y row relative to parent
+      * @param width width of tree view
+      * @param height height of tree view
+      * @param action action to perform when an item is selected
+      */
+     public TTreeViewWidget(final TWidget parent, final int x, final int y,
+         final int width, final int height, final TAction action) {
+         super(parent, x, y, width, height);
+         treeView = new TTreeView(this, 0, 0, getWidth() - 1, getHeight() - 1,
+             action);
+         vScroller = new TVScroller(this, getWidth() - 1, 0, getHeight() - 1);
+         hScroller = new THScroller(this, 0, getHeight() - 1, getWidth() - 1);
+     }
+     // ------------------------------------------------------------------------
+     // Event handlers ---------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Handle window/screen resize events.
+      *
+      * @param event resize event
+      */
+     @Override
+     public void onResize(final TResizeEvent event) {
+         super.onResize(event);
+         if (event.getType() == TResizeEvent.Type.WIDGET) {
+             treeView.onResize(new TResizeEvent(TResizeEvent.Type.WIDGET,
+                     getWidth() - 1, getHeight() - 1));
+             return;
+         } else {
+             super.onResize(event);
+         }
+     }
+     /**
+      * Handle mouse press events.
+      *
+      * @param mouse mouse button press event
+      */
+     @Override
+     public void onMouseDown(final TMouseEvent mouse) {
+         if (mouse.isMouseWheelUp()) {
+             verticalDecrement();
+         } else if (mouse.isMouseWheelDown()) {
+             verticalIncrement();
+         } else {
+             // Pass to the TreeView or scrollbars
+             super.onMouseDown(mouse);
+         }
+         // Update the view to reflect the new scrollbar positions
+         treeView.setTopLine(getVerticalValue());
+         treeView.setLeftColumn(getHorizontalValue());
+         reflowData();
+     }
+     /**
+      * Handle mouse release events.
+      *
+      * @param mouse mouse button release event
+      */
+     @Override
+     public void onMouseUp(final TMouseEvent mouse) {
+         // Pass to the TreeView or scrollbars
+         super.onMouseUp(mouse);
+         // Update the view to reflect the new scrollbar positions
+         treeView.setTopLine(getVerticalValue());
+         treeView.setLeftColumn(getHorizontalValue());
+         reflowData();
+     }
+     /**
+      * Handle mouse motion events.
+      *
+      * @param mouse mouse motion event
+      */
+     @Override
+     public void onMouseMotion(final TMouseEvent mouse) {
+         // Pass to the TreeView or scrollbars
+         super.onMouseMotion(mouse);
+         // Update the view to reflect the new scrollbar positions
+         treeView.setTopLine(getVerticalValue());
+         treeView.setLeftColumn(getHorizontalValue());
+         reflowData();
+     }
+     /**
+      * Handle keystrokes.
+      *
+      * @param keypress keystroke event
+      */
+     @Override
+     public void onKeypress(final TKeypressEvent keypress) {
+         if (keypress.equals(kbShiftLeft)
+             || keypress.equals(kbCtrlLeft)
+             || keypress.equals(kbAltLeft)
+         ) {
+             horizontalDecrement();
+         } else if (keypress.equals(kbShiftRight)
+             || keypress.equals(kbCtrlRight)
+             || keypress.equals(kbAltRight)
+         ) {
+             horizontalIncrement();
+         } else if (keypress.equals(kbShiftUp)
+             || keypress.equals(kbCtrlUp)
+             || keypress.equals(kbAltUp)
+         ) {
+             verticalDecrement();
+         } else if (keypress.equals(kbShiftDown)
+             || keypress.equals(kbCtrlDown)
+             || keypress.equals(kbAltDown)
+         ) {
+             verticalIncrement();
+         } else if (keypress.equals(kbShiftPgUp)
+             || keypress.equals(kbCtrlPgUp)
+             || keypress.equals(kbAltPgUp)
+         ) {
+             bigVerticalDecrement();
+         } else if (keypress.equals(kbShiftPgDn)
+             || keypress.equals(kbCtrlPgDn)
+             || keypress.equals(kbAltPgDn)
+         ) {
+             bigVerticalIncrement();
+         } else if (keypress.equals(kbPgDn)) {
+             for (int i = 0; i < getHeight() - 2; i++) {
+                 treeView.onKeypress(new TKeypressEvent(TKeypress.kbDown));
+             }
+             reflowData();
+             return;
+         } else if (keypress.equals(kbPgUp)) {
+             for (int i = 0; i < getHeight() - 2; i++) {
+                 treeView.onKeypress(new TKeypressEvent(TKeypress.kbUp));
+             }
+             reflowData();
+             return;
+         } else if (keypress.equals(kbHome)) {
+             treeView.setSelected((TTreeItem) treeView.getChildren().get(0),
+                 false);
+             treeView.setTopLine(0);
+             reflowData();
+             return;
+         } else if (keypress.equals(kbEnd)) {
+             treeView.setSelected((TTreeItem)  treeView.getChildren().get(
+                 treeView.getChildren().size() - 1), true);
+             reflowData();
+             return;
+         } else if (keypress.equals(kbTab)) {
+             getParent().switchWidget(true);
+             return;
+         } else if (keypress.equals(kbShiftTab)
+                 || keypress.equals(kbBackTab)) {
+             getParent().switchWidget(false);
+             return;
+         } else {
+             treeView.onKeypress(keypress);
+             // Update the scrollbars to reflect the new data position
+             reflowData();
+             return;
+         }
+         // Update the view to reflect the new scrollbar position
+         treeView.setTopLine(getVerticalValue());
+         treeView.setLeftColumn(getHorizontalValue());
+         reflowData();
+     }
+     // ------------------------------------------------------------------------
+     // TScrollableWidget ------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Resize text and scrollbars for a new width/height.
+      */
+     @Override
+     public void reflowData() {
+         int selectedRow = 0;
+         boolean foundSelectedRow = false;
+         // Reset the keyboard list, expandTree() will recreate it.
+         for (TWidget widget: treeView.getChildren()) {
+             TTreeItem item = (TTreeItem) widget;
+             item.keyboardPrevious = null;
+             item.keyboardNext = null;
+         }
+         // Expand the tree into a linear list
+         treeView.getChildren().clear();
+         treeView.getChildren().addAll(treeView.getTreeRoot().expandTree("",
+                 true));
+         // Locate the selected row and maximum line width
+         for (TWidget widget: treeView.getChildren()) {
+             TTreeItem item = (TTreeItem) widget;
+             if (item == treeView.getSelected()) {
+                 foundSelectedRow = true;
+             }
+             if (!foundSelectedRow) {
+                 selectedRow++;
+             }
+             int lineWidth = StringUtils.width(item.getText())
+                 + item.getPrefix().length() + 4;
+             if (lineWidth > maxLineWidth) {
+                 maxLineWidth = lineWidth;
+             }
+         }
+         if ((centerWindow) && (foundSelectedRow)) {
+             if ((selectedRow < getVerticalValue())
+                 || (selectedRow > getVerticalValue() + getHeight() - 2)
+             ) {
+                 treeView.setTopLine(selectedRow);
+                 centerWindow = false;
+             }
+         }
+         treeView.alignTree();
+         // Rescale the scroll bars
+         setVerticalValue(treeView.getTopLine());
+         setBottomValue(treeView.getTotalLineCount() - (getHeight() - 1));
+         if (getBottomValue() < getTopValue()) {
+             setBottomValue(getTopValue());
+         }
+         if (getVerticalValue() > getBottomValue()) {
+             setVerticalValue(getBottomValue());
+         }
+         setRightValue(maxLineWidth - 2);
+         if (getHorizontalValue() > getRightValue()) {
+             setHorizontalValue(getRightValue());
+         }
+     }
+     // ------------------------------------------------------------------------
+     // TTreeView --------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Get the underlying TTreeView.
+      *
+      * @return the TTreeView
+      */
+     public TTreeView getTreeView() {
+         return treeView;
+     }
+     /**
+      * Get the root of the tree.
+      *
+      * @return the root of the tree
+      */
+     public final TTreeItem getTreeRoot() {
+         return treeView.getTreeRoot();
+     }
+     /**
+      * Set the root of the tree.
+      *
+      * @param treeRoot the new root of the tree
+      */
+     public final void setTreeRoot(final TTreeItem treeRoot) {
+         treeView.setTreeRoot(treeRoot);
+     }
+     /**
+      * Set treeRoot.
+      *
+      * @param treeRoot ultimate root of tree
+      * @param centerWindow if true, move the window to put the root in view
+      */
+     public void setTreeRoot(final TTreeItem treeRoot,
+         final boolean centerWindow) {
+         treeView.setTreeRoot(treeRoot);
+         this.centerWindow = centerWindow;
+     }
+     /**
+      * Get the tree view item that was selected.
+      *
+      * @return the selected item, or null if no item is selected
+      */
+     public final TTreeItem getSelected() {
+         return treeView.getSelected();
+     }
+     /**
+      * Set the new selected tree view item.
+      *
+      * @param item new item that became selected
+      * @param centerWindow if true, move the window to put the selected into
+      * view
+      */
+     public void setSelected(final TTreeItem item, final boolean centerWindow) {
+         treeView.setSelected(item, centerWindow);
+     }
+     /**
+      * Perform user selection action.
+      */
+     public void dispatch() {
+         treeView.dispatch();
+     }
+ }
index 0000000000000000000000000000000000000000,f418383ecf43d637c14b81cceedd914b44b54179..f418383ecf43d637c14b81cceedd914b44b54179
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,408 +1,408 @@@
+ /*
+  * Jexer - Java Text User Interface
+  *
+  * The MIT License (MIT)
+  *
+  * 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"),
+  * to deal in the Software without restriction, including without limitation
+  * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+  * and/or sell copies of the Software, and to permit persons to whom the
+  * Software is furnished to do so, subject to the following conditions:
+  *
+  * The above copyright notice and this permission notice shall be included in
+  * all copies or substantial portions of the Software.
+  *
+  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+  * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+  * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+  * DEALINGS IN THE SOFTWARE.
+  *
+  * @author Kevin Lamonte [kevin.lamonte@gmail.com]
+  * @version 1
+  */
+ package jexer.ttree;
+ import jexer.TAction;
+ import jexer.TApplication;
+ import jexer.THScroller;
+ import jexer.TScrollableWindow;
+ import jexer.TVScroller;
+ import jexer.TWidget;
+ import jexer.bits.StringUtils;
+ import jexer.event.TKeypressEvent;
+ import jexer.event.TMouseEvent;
+ import jexer.event.TResizeEvent;
+ import static jexer.TKeypress.*;
+ /**
+  * TTreeViewWindow wraps a tree view with horizontal and vertical scrollbars
+  * in a standalone window.
+  */
+ public class TTreeViewWindow extends TScrollableWindow {
+     // ------------------------------------------------------------------------
+     // Variables --------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * The TTreeView
+      */
+     private TTreeView treeView;
+     /**
+      * If true, move the window to put the selected item in view.  This
+      * normally only happens once after setting treeRoot.
+      */
+     private boolean centerWindow = false;
+     /**
+      * Maximum width of a single line.
+      */
+     private int maxLineWidth;
+     // ------------------------------------------------------------------------
+     // Constructors -----------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Public constructor.
+      *
+      * @param parent the main application
+      * @param title the window title
+      * @param x column relative to parent
+      * @param y row relative to parent
+      * @param width width of tree view
+      * @param flags bitmask of RESIZABLE, CENTERED, or MODAL
+      * @param height height of tree view
+      */
+     public TTreeViewWindow(final TApplication parent, final String title,
+         final int x, final int y, final int width, final int height,
+         final int flags) {
+         this(parent, title, x, y, width, height, flags, null);
+     }
+     /**
+      * Public constructor.
+      *
+      * @param parent the main application
+      * @param title the window title
+      * @param x column relative to parent
+      * @param y row relative to parent
+      * @param width width of tree view
+      * @param height height of tree view
+      * @param flags bitmask of RESIZABLE, CENTERED, or MODAL
+      * @param action action to perform when an item is selected
+      */
+     public TTreeViewWindow(final TApplication parent, final String title,
+         final int x, final int y, final int width, final int height,
+         final int flags, final TAction action) {
+         super(parent, title, x, y, width, height, flags);
+         treeView = new TTreeView(this, 0, 0, getWidth() - 2, getHeight() - 2,
+             action);
+         hScroller = new THScroller(this, 17, getHeight() - 2, getWidth() - 20);
+         vScroller = new TVScroller(this, getWidth() - 2, 0, getHeight() - 2);
+         /*
+         System.err.println("TTreeViewWindow()");
+         for (TWidget w: getChildren()) {
+             System.err.println("    " + w + " " + w.isActive());
+         }
+         */
+     }
+     // ------------------------------------------------------------------------
+     // Event handlers ---------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Handle mouse press events.
+      *
+      * @param mouse mouse button press event
+      */
+     @Override
+     public void onMouseDown(final TMouseEvent mouse) {
+         if (mouse.isMouseWheelUp()) {
+             verticalDecrement();
+         } else if (mouse.isMouseWheelDown()) {
+             verticalIncrement();
+         } else {
+             // Pass to the TreeView or scrollbars
+             super.onMouseDown(mouse);
+         }
+         // Update the view to reflect the new scrollbar positions
+         treeView.setTopLine(getVerticalValue());
+         treeView.setLeftColumn(getHorizontalValue());
+         reflowData();
+     }
+     /**
+      * Handle mouse release events.
+      *
+      * @param mouse mouse button release event
+      */
+     @Override
+     public void onMouseUp(final TMouseEvent mouse) {
+         // Pass to the TreeView or scrollbars
+         super.onMouseUp(mouse);
+         // Update the view to reflect the new scrollbar positions
+         treeView.setTopLine(getVerticalValue());
+         treeView.setLeftColumn(getHorizontalValue());
+         reflowData();
+     }
+     /**
+      * Handle mouse motion events.
+      *
+      * @param mouse mouse motion event
+      */
+     @Override
+     public void onMouseMotion(final TMouseEvent mouse) {
+         // Pass to the TreeView or scrollbars
+         super.onMouseMotion(mouse);
+         // Update the view to reflect the new scrollbar positions
+         treeView.setTopLine(getVerticalValue());
+         treeView.setLeftColumn(getHorizontalValue());
+         reflowData();
+     }
+     /**
+      * Handle keystrokes.
+      *
+      * @param keypress keystroke event
+      */
+     @Override
+     public void onKeypress(final TKeypressEvent keypress) {
+         if (inKeyboardResize) {
+             // Let TWindow do its job.
+             super.onKeypress(keypress);
+             return;
+         }
+         // Give the shortcut bar a shot at this.
+         if (statusBar != null) {
+             if (statusBar.statusBarKeypress(keypress)) {
+                 return;
+             }
+         }
+         if (keypress.equals(kbShiftLeft)
+             || keypress.equals(kbCtrlLeft)
+             || keypress.equals(kbAltLeft)
+         ) {
+             horizontalDecrement();
+         } else if (keypress.equals(kbShiftRight)
+             || keypress.equals(kbCtrlRight)
+             || keypress.equals(kbAltRight)
+         ) {
+             horizontalIncrement();
+         } else if (keypress.equals(kbShiftUp)
+             || keypress.equals(kbCtrlUp)
+             || keypress.equals(kbAltUp)
+         ) {
+             verticalDecrement();
+         } else if (keypress.equals(kbShiftDown)
+             || keypress.equals(kbCtrlDown)
+             || keypress.equals(kbAltDown)
+         ) {
+             verticalIncrement();
+         } else if (keypress.equals(kbShiftPgUp)
+             || keypress.equals(kbCtrlPgUp)
+             || keypress.equals(kbAltPgUp)
+         ) {
+             bigVerticalDecrement();
+         } else if (keypress.equals(kbShiftPgDn)
+             || keypress.equals(kbCtrlPgDn)
+             || keypress.equals(kbAltPgDn)
+         ) {
+             bigVerticalIncrement();
+         } else {
+             treeView.onKeypress(keypress);
+             // Update the scrollbars to reflect the new data position
+             reflowData();
+             return;
+         }
+         // Update the view to reflect the new scrollbar position
+         treeView.setTopLine(getVerticalValue());
+         treeView.setLeftColumn(getHorizontalValue());
+         reflowData();
+     }
+     // ------------------------------------------------------------------------
+     // TScrollableWindow ------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Handle window/screen resize events.
+      *
+      * @param resize resize event
+      */
+     @Override
+     public void onResize(final TResizeEvent resize) {
+         if (resize.getType() == TResizeEvent.Type.WIDGET) {
+             // Resize the treeView field.
+             TResizeEvent treeSize = new TResizeEvent(TResizeEvent.Type.WIDGET,
+                 resize.getWidth() - 2, resize.getHeight() - 2);
+             treeView.onResize(treeSize);
+             // Have TScrollableWindow handle the scrollbars.
+             super.onResize(resize);
+             // Now re-center the treeView field.
+             if (treeView.getSelected() != null) {
+                 treeView.setSelected(treeView.getSelected(), true);
+             }
+             reflowData();
+             return;
+         }
+     }
+     /**
+      * Resize text and scrollbars for a new width/height.
+      */
+     @Override
+     public void reflowData() {
+         int selectedRow = 0;
+         boolean foundSelectedRow = false;
+         // Reset the keyboard list, expandTree() will recreate it.
+         for (TWidget widget: treeView.getChildren()) {
+             TTreeItem item = (TTreeItem) widget;
+             item.keyboardPrevious = null;
+             item.keyboardNext = null;
+         }
+         // Expand the tree into a linear list
+         treeView.getChildren().clear();
+         treeView.getChildren().addAll(treeView.getTreeRoot().expandTree("",
+                 true));
+         // Locate the selected row and maximum line width
+         for (TWidget widget: treeView.getChildren()) {
+             TTreeItem item = (TTreeItem) widget;
+             if (item == treeView.getSelected()) {
+                 foundSelectedRow = true;
+             }
+             if (!foundSelectedRow) {
+                 selectedRow++;
+             }
+             int lineWidth = StringUtils.width(item.getText())
+                 + item.getPrefix().length() + 4;
+             if (lineWidth > maxLineWidth) {
+                 maxLineWidth = lineWidth;
+             }
+         }
+         if ((centerWindow) && (foundSelectedRow)) {
+             if ((selectedRow < getVerticalValue())
+                 || (selectedRow > getVerticalValue() + getHeight() - 3)
+             ) {
+                 treeView.setTopLine(selectedRow);
+                 centerWindow = false;
+             }
+         }
+         treeView.alignTree();
+         // Rescale the scroll bars
+         setVerticalValue(treeView.getTopLine());
+         setBottomValue(treeView.getTotalLineCount() - (getHeight() - 2));
+         if (getBottomValue() < getTopValue()) {
+             setBottomValue(getTopValue());
+         }
+         if (getVerticalValue() > getBottomValue()) {
+             setVerticalValue(getBottomValue());
+         }
+         setRightValue(maxLineWidth - 4);
+         if (getHorizontalValue() > getRightValue()) {
+             setHorizontalValue(getRightValue());
+         }
+     }
+     // ------------------------------------------------------------------------
+     // TTreeView --------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Get the underlying TTreeView.
+      *
+      * @return the TTreeView
+      */
+     public TTreeView getTreeView() {
+         return treeView;
+     }
+     /**
+      * Get the root of the tree.
+      *
+      * @return the root of the tree
+      */
+     public final TTreeItem getTreeRoot() {
+         return treeView.getTreeRoot();
+     }
+     /**
+      * Set the root of the tree.
+      *
+      * @param treeRoot the new root of the tree
+      */
+     public final void setTreeRoot(final TTreeItem treeRoot) {
+         treeView.setTreeRoot(treeRoot);
+     }
+     /**
+      * Set treeRoot.
+      *
+      * @param treeRoot ultimate root of tree
+      * @param centerWindow if true, move the window to put the root in view
+      */
+     public void setTreeRoot(final TTreeItem treeRoot,
+         final boolean centerWindow) {
+         treeView.setTreeRoot(treeRoot);
+         this.centerWindow = centerWindow;
+     }
+     /**
+      * Get the tree view item that was selected.
+      *
+      * @return the selected item, or null if no item is selected
+      */
+     public final TTreeItem getSelected() {
+         return treeView.getSelected();
+     }
+     /**
+      * Set the new selected tree view item.
+      *
+      * @param item new item that became selected
+      * @param centerWindow if true, move the window to put the selected into
+      * view
+      */
+     public void setSelected(final TTreeItem item, final boolean centerWindow) {
+         treeView.setSelected(item, centerWindow);
+     }
+     /**
+      * Perform user selection action.
+      */
+     public void dispatch() {
+         treeView.dispatch();
+     }
+ }
index 0000000000000000000000000000000000000000,1e1fdfd0fcab836658eddf6751f136d74a15da34..1e1fdfd0fcab836658eddf6751f136d74a15da34
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,33 +1,33 @@@
+ /*
+  * Jexer - Java Text User Interface
+  *
+  * The MIT License (MIT)
+  *
+  * 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"),
+  * to deal in the Software without restriction, including without limitation
+  * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+  * and/or sell copies of the Software, and to permit persons to whom the
+  * Software is furnished to do so, subject to the following conditions:
+  *
+  * The above copyright notice and this permission notice shall be included in
+  * all copies or substantial portions of the Software.
+  *
+  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+  * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+  * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+  * DEALINGS IN THE SOFTWARE.
+  *
+  * @author Kevin Lamonte [kevin.lamonte@gmail.com]
+  * @version 1
+  */
+ /**
+  * TTreeView and supporting classes.
+  */
+ package jexer.ttree;