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

diff --git a/src/jexer/.classpath b/src/jexer/.classpath
new file mode 100644 (file)
index 0000000..9b07da8
--- /dev/null
@@ -0,0 +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 --git a/src/jexer/.gitignore b/src/jexer/.gitignore
new file mode 100644 (file)
index 0000000..30d9f7c
--- /dev/null
@@ -0,0 +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 --git a/src/jexer/.project b/src/jexer/.project
new file mode 100644 (file)
index 0000000..c0afd85
--- /dev/null
@@ -0,0 +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>
diff --git a/src/jexer/Scrollable.java b/src/jexer/Scrollable.java
new file mode 100644 (file)
index 0000000..b844ca6
--- /dev/null
@@ -0,0 +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 --git a/src/jexer/TAction.java b/src/jexer/TAction.java
new file mode 100644 (file)
index 0000000..5343143
--- /dev/null
@@ -0,0 +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();
+}
diff --git a/src/jexer/TApplication.java b/src/jexer/TApplication.java
new file mode 100644 (file)
index 0000000..9d27c10
--- /dev/null
@@ -0,0 +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;
+    }
+
+}
diff --git a/src/jexer/TApplication.properties b/src/jexer/TApplication.properties
new file mode 100644 (file)
index 0000000..299c6a3
--- /dev/null
@@ -0,0 +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 --git a/src/jexer/TButton.java b/src/jexer/TButton.java
new file mode 100644 (file)
index 0000000..d86fa44
--- /dev/null
@@ -0,0 +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 --git a/src/jexer/TCalendar.java b/src/jexer/TCalendar.java
new file mode 100644 (file)
index 0000000..c2005cc
--- /dev/null
@@ -0,0 +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 --git a/src/jexer/TCheckBox.java b/src/jexer/TCheckBox.java
new file mode 100644 (file)
index 0000000..1f9a351
--- /dev/null
@@ -0,0 +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 --git a/src/jexer/TComboBox.java b/src/jexer/TComboBox.java
new file mode 100644 (file)
index 0000000..1164e6c
--- /dev/null
@@ -0,0 +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 --git a/src/jexer/TCommand.java b/src/jexer/TCommand.java
new file mode 100644 (file)
index 0000000..874a29d
--- /dev/null
@@ -0,0 +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 --git a/src/jexer/TDesktop.java b/src/jexer/TDesktop.java
new file mode 100644 (file)
index 0000000..5aa52af
--- /dev/null
@@ -0,0 +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;
+    }
+
+}
diff --git a/src/jexer/TDirectoryList.java b/src/jexer/TDirectoryList.java
new file mode 100644 (file)
index 0000000..322ff5c
--- /dev/null
@@ -0,0 +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));
+    }
+
+}
diff --git a/src/jexer/TEditColorThemeWindow.java b/src/jexer/TEditColorThemeWindow.java
new file mode 100644 (file)
index 0000000..668309d
--- /dev/null
@@ -0,0 +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);
+    }
+
+}
diff --git a/src/jexer/TEditColorThemeWindow.properties b/src/jexer/TEditColorThemeWindow.properties
new file mode 100644 (file)
index 0000000..f4c6220
--- /dev/null
@@ -0,0 +1,8 @@
+foregroundLabel=\ Foreground\ 
+backgroundLabel=\ Background\ 
+windowTitle=Colors
+okButton=\ \ &OK\ \ 
+cancelButton=&Cancel
+statusBar=Select Colors
+colorName=Color Name
+textTextText=Text Text Text
diff --git a/src/jexer/TEditorWidget.java b/src/jexer/TEditorWidget.java
new file mode 100644 (file)
index 0000000..a694533
--- /dev/null
@@ -0,0 +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);
+    }
+
+}
diff --git a/src/jexer/TEditorWindow.java b/src/jexer/TEditorWindow.java
new file mode 100644 (file)
index 0000000..d78185c
--- /dev/null
@@ -0,0 +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;
+    }
+
+}
diff --git a/src/jexer/TEditorWindow.properties b/src/jexer/TEditorWindow.properties
new file mode 100644 (file)
index 0000000..d18b078
--- /dev/null
@@ -0,0 +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}
diff --git a/src/jexer/TExceptionDialog.java b/src/jexer/TExceptionDialog.java
new file mode 100644 (file)
index 0000000..227aceb
--- /dev/null
@@ -0,0 +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;
+            }
+        }
+    }
+}
diff --git a/src/jexer/TExceptionDialog.properties b/src/jexer/TExceptionDialog.properties
new file mode 100644 (file)
index 0000000..d07998c
--- /dev/null
@@ -0,0 +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 --git a/src/jexer/TField.java b/src/jexer/TField.java
new file mode 100644 (file)
index 0000000..7c8b5bc
--- /dev/null
@@ -0,0 +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;
+    }
+
+}
diff --git a/src/jexer/TFileOpenBox.java b/src/jexer/TFileOpenBox.java
new file mode 100644 (file)
index 0000000..a2cc0cf
--- /dev/null
@@ -0,0 +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;
+        }
+    }
+
+}
diff --git a/src/jexer/TFileOpenBox.properties b/src/jexer/TFileOpenBox.properties
new file mode 100644 (file)
index 0000000..ef40e86
--- /dev/null
@@ -0,0 +1,7 @@
+openButton=\ &Open\ 
+openTitle=Open File...
+saveButton=\ &Save\ 
+saveTitle=Save File...
+cancelButton=&Cancel
+selectButton=S&elect
+selectTitle=Select File...
diff --git a/src/jexer/TFontChooserWindow.java b/src/jexer/TFontChooserWindow.java
new file mode 100644 (file)
index 0000000..62eabb6
--- /dev/null
@@ -0,0 +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 -----------------------------------------------------
+    // ------------------------------------------------------------------------
+
+}
diff --git a/src/jexer/TFontChooserWindow.properties b/src/jexer/TFontChooserWindow.properties
new file mode 100644 (file)
index 0000000..4ab274e
--- /dev/null
@@ -0,0 +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\ 
diff --git a/src/jexer/THScroller.java b/src/jexer/THScroller.java
new file mode 100644 (file)
index 0000000..a07bcd7
--- /dev/null
@@ -0,0 +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 --git a/src/jexer/TImage.java b/src/jexer/TImage.java
new file mode 100644 (file)
index 0000000..cd0ce96
--- /dev/null
@@ -0,0 +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;
+    }
+
+}
diff --git a/src/jexer/TImageWindow.java b/src/jexer/TImageWindow.java
new file mode 100644 (file)
index 0000000..15db1da
--- /dev/null
@@ -0,0 +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());
+    }
+
+}
diff --git a/src/jexer/TImageWindow.properties b/src/jexer/TImageWindow.properties
new file mode 100644 (file)
index 0000000..a26fce5
--- /dev/null
@@ -0,0 +1 @@
+statusBar=Alt-\u2190\u2192-Rotate Left/Right  Alt-\u2191\u2193-Bigger/Smaller  \u2190\u2192\u2191\u2193-Pan  Shift-\u2190\u2192-Scale
diff --git a/src/jexer/TInputBox.java b/src/jexer/TInputBox.java
new file mode 100644 (file)
index 0000000..d60d0b5
--- /dev/null
@@ -0,0 +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 --git a/src/jexer/TKeypress.java b/src/jexer/TKeypress.java
new file mode 100644 (file)
index 0000000..c965e7d
--- /dev/null
@@ -0,0 +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 --git a/src/jexer/TLabel.java b/src/jexer/TLabel.java
new file mode 100644 (file)
index 0000000..cc341cf
--- /dev/null
@@ -0,0 +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 --git a/src/jexer/TList.java b/src/jexer/TList.java
new file mode 100644 (file)
index 0000000..38a994c
--- /dev/null
@@ -0,0 +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);
+        }
+    }
+
+}
diff --git a/src/jexer/TMessageBox.java b/src/jexer/TMessageBox.java
new file mode 100644 (file)
index 0000000..6f1e8a6
--- /dev/null
@@ -0,0 +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);
+    }
+
+}
diff --git a/src/jexer/TMessageBox.properties b/src/jexer/TMessageBox.properties
new file mode 100644 (file)
index 0000000..04e344a
--- /dev/null
@@ -0,0 +1,4 @@
+okButton=\ \ &OK\ \ 
+cancelButton=&Cancel
+yesButton=&Yes
+noButton=&No
diff --git a/src/jexer/TPanel.java b/src/jexer/TPanel.java
new file mode 100644 (file)
index 0000000..c38f8e1
--- /dev/null
@@ -0,0 +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 -----------------------------------------------------------------
+    // ------------------------------------------------------------------------
+
+
+}
diff --git a/src/jexer/TPasswordField.java b/src/jexer/TPasswordField.java
new file mode 100644 (file)
index 0000000..9c200d7
--- /dev/null
@@ -0,0 +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();
+    }
+
+}
diff --git a/src/jexer/TProgressBar.java b/src/jexer/TProgressBar.java
new file mode 100644 (file)
index 0000000..38f0337
--- /dev/null
@@ -0,0 +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;
+    }
+
+}
diff --git a/src/jexer/TRadioButton.java b/src/jexer/TRadioButton.java
new file mode 100644 (file)
index 0000000..60a6288
--- /dev/null
@@ -0,0 +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;
+    }
+
+}
diff --git a/src/jexer/TRadioGroup.java b/src/jexer/TRadioGroup.java
new file mode 100644 (file)
index 0000000..a82b074
--- /dev/null
@@ -0,0 +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;
+    }
+
+}
diff --git a/src/jexer/TScrollableWidget.java b/src/jexer/TScrollableWidget.java
new file mode 100644 (file)
index 0000000..7d15b28
--- /dev/null
@@ -0,0 +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();
+        }
+    }
+
+}
diff --git a/src/jexer/TScrollableWindow.java b/src/jexer/TScrollableWindow.java
new file mode 100644 (file)
index 0000000..1e260b3
--- /dev/null
@@ -0,0 +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 --git a/src/jexer/TSpinner.java b/src/jexer/TSpinner.java
new file mode 100644 (file)
index 0000000..61fac65
--- /dev/null
@@ -0,0 +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);
+        }
+    }
+
+}
diff --git a/src/jexer/TSplitPane.java b/src/jexer/TSplitPane.java
new file mode 100644 (file)
index 0000000..7c85278
--- /dev/null
@@ -0,0 +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;
+    }
+
+}
diff --git a/src/jexer/TStatusBar.java b/src/jexer/TStatusBar.java
new file mode 100644 (file)
index 0000000..fbd79da
--- /dev/null
@@ -0,0 +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;
+    }
+
+}
diff --git a/src/jexer/TTableWidget.java b/src/jexer/TTableWidget.java
new file mode 100644 (file)
index 0000000..9b4d7c9
--- /dev/null
@@ -0,0 +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();
+    }
+
+}
diff --git a/src/jexer/TTableWindow.java b/src/jexer/TTableWindow.java
new file mode 100644 (file)
index 0000000..44ff7b4
--- /dev/null
@@ -0,0 +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;
+    }
+
+}
diff --git a/src/jexer/TTableWindow.properties b/src/jexer/TTableWindow.properties
new file mode 100644 (file)
index 0000000..c2c8765
--- /dev/null
@@ -0,0 +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}
diff --git a/src/jexer/TTerminalWidget.java b/src/jexer/TTerminalWidget.java
new file mode 100644 (file)
index 0000000..a269609
--- /dev/null
@@ -0,0 +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;
+    }
+
+}
diff --git a/src/jexer/TTerminalWidget.properties b/src/jexer/TTerminalWidget.properties
new file mode 100644 (file)
index 0000000..ecfcf21
--- /dev/null
@@ -0,0 +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}.
diff --git a/src/jexer/TTerminalWindow.java b/src/jexer/TTerminalWindow.java
new file mode 100644 (file)
index 0000000..e96c50c
--- /dev/null
@@ -0,0 +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));
+    }
+
+}
diff --git a/src/jexer/TTerminalWindow.properties b/src/jexer/TTerminalWindow.properties
new file mode 100644 (file)
index 0000000..ed22f49
--- /dev/null
@@ -0,0 +1,2 @@
+windowTitle=Terminal
+statusBarRunning=Terminal session executing...
diff --git a/src/jexer/TText.java b/src/jexer/TText.java
new file mode 100644 (file)
index 0000000..22bc4b8
--- /dev/null
@@ -0,0 +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 --git a/src/jexer/TTimer.java b/src/jexer/TTimer.java
new file mode 100644 (file)
index 0000000..8007153
--- /dev/null
@@ -0,0 +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);
+        }
+    }
+
+}
diff --git a/src/jexer/TVScroller.java b/src/jexer/TVScroller.java
new file mode 100644 (file)
index 0000000..444e058
--- /dev/null
@@ -0,0 +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 --git a/src/jexer/TWidget.java b/src/jexer/TWidget.java
new file mode 100644 (file)
index 0000000..eb06175
--- /dev/null
@@ -0,0 +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 --git a/src/jexer/TWindow.java b/src/jexer/TWindow.java
new file mode 100644 (file)
index 0000000..58195c9
--- /dev/null
@@ -0,0 +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());
+    }
+
+}
diff --git a/src/jexer/backend/Backend.java b/src/jexer/backend/Backend.java
new file mode 100644 (file)
index 0000000..eaed7e6
--- /dev/null
@@ -0,0 +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();
+
+}
diff --git a/src/jexer/backend/ECMA48Backend.java b/src/jexer/backend/ECMA48Backend.java
new file mode 100644 (file)
index 0000000..0614e17
--- /dev/null
@@ -0,0 +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);
+    }
+
+}
diff --git a/src/jexer/backend/ECMA48Terminal.java b/src/jexer/backend/ECMA48Terminal.java
new file mode 100644 (file)
index 0000000..e2997d2
--- /dev/null
@@ -0,0 +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\\";
+    }
+
+}
diff --git a/src/jexer/backend/GenericBackend.java b/src/jexer/backend/GenericBackend.java
new file mode 100644 (file)
index 0000000..ede3c0b
--- /dev/null
@@ -0,0 +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();
+    }
+
+}
diff --git a/src/jexer/backend/GlyphMaker.java b/src/jexer/backend/GlyphMaker.java
new file mode 100644 (file)
index 0000000..0da2918
--- /dev/null
@@ -0,0 +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);
+    }
+
+}
diff --git a/src/jexer/backend/LogicalScreen.java b/src/jexer/backend/LogicalScreen.java
new file mode 100644 (file)
index 0000000..4e4aecc
--- /dev/null
@@ -0,0 +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);
+    }
+
+}
diff --git a/src/jexer/backend/MultiBackend.java b/src/jexer/backend/MultiBackend.java
new file mode 100644 (file)
index 0000000..d01b944
--- /dev/null
@@ -0,0 +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);
+        }
+    }
+
+}
diff --git a/src/jexer/backend/MultiScreen.java b/src/jexer/backend/MultiScreen.java
new file mode 100644 (file)
index 0000000..9d66b69
--- /dev/null
@@ -0,0 +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;
+    }
+
+}
diff --git a/src/jexer/backend/Screen.java b/src/jexer/backend/Screen.java
new file mode 100644 (file)
index 0000000..2a71073
--- /dev/null
@@ -0,0 +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();
+
+}
diff --git a/src/jexer/backend/SessionInfo.java b/src/jexer/backend/SessionInfo.java
new file mode 100644 (file)
index 0000000..8a29ce0
--- /dev/null
@@ -0,0 +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();
+}
diff --git a/src/jexer/backend/SwingBackend.java b/src/jexer/backend/SwingBackend.java
new file mode 100644 (file)
index 0000000..8a342b6
--- /dev/null
@@ -0,0 +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();
+    }
+
+}
diff --git a/src/jexer/backend/SwingComponent.java b/src/jexer/backend/SwingComponent.java
new file mode 100644 (file)
index 0000000..3d1074c
--- /dev/null
@@ -0,0 +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();
+        }
+    }
+
+}
diff --git a/src/jexer/backend/SwingSessionInfo.java b/src/jexer/backend/SwingSessionInfo.java
new file mode 100644 (file)
index 0000000..2f74d70
--- /dev/null
@@ -0,0 +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;
+    }
+
+}
diff --git a/src/jexer/backend/SwingTerminal.java b/src/jexer/backend/SwingTerminal.java
new file mode 100644 (file)
index 0000000..f0ba355
--- /dev/null
@@ -0,0 +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();
+            }
+        }
+    }
+
+}
diff --git a/src/jexer/backend/TSessionInfo.java b/src/jexer/backend/TSessionInfo.java
new file mode 100644 (file)
index 0000000..ccddce4
--- /dev/null
@@ -0,0 +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
+    }
+
+}
diff --git a/src/jexer/backend/TTYSessionInfo.java b/src/jexer/backend/TTYSessionInfo.java
new file mode 100644 (file)
index 0000000..d7f5bc8
--- /dev/null
@@ -0,0 +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();
+        }
+    }
+
+}
diff --git a/src/jexer/backend/TWindowBackend.java b/src/jexer/backend/TWindowBackend.java
new file mode 100644 (file)
index 0000000..f644b76
--- /dev/null
@@ -0,0 +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;
+    }
+
+}
diff --git a/src/jexer/backend/TerminalReader.java b/src/jexer/backend/TerminalReader.java
new file mode 100644 (file)
index 0000000..32033e0
--- /dev/null
@@ -0,0 +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();
+
+}
diff --git a/src/jexer/backend/package-info.java b/src/jexer/backend/package-info.java
new file mode 100644 (file)
index 0000000..46d8ba1
--- /dev/null
@@ -0,0 +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 --git a/src/jexer/bits/Cell.java b/src/jexer/bits/Cell.java
new file mode 100644 (file)
index 0000000..a8efa2b
--- /dev/null
@@ -0,0 +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);
+    }
+}
diff --git a/src/jexer/bits/CellAttributes.java b/src/jexer/bits/CellAttributes.java
new file mode 100644 (file)
index 0000000..99366fd
--- /dev/null
@@ -0,0 +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);
+    }
+
+}
diff --git a/src/jexer/bits/Color.java b/src/jexer/bits/Color.java
new file mode 100644 (file)
index 0000000..4defed5
--- /dev/null
@@ -0,0 +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);
+        }
+    }
+
+}
diff --git a/src/jexer/bits/ColorTheme.java b/src/jexer/bits/ColorTheme.java
new file mode 100644 (file)
index 0000000..ffba4d4
--- /dev/null
@@ -0,0 +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();
+    }
+
+}
diff --git a/src/jexer/bits/GraphicsChars.java b/src/jexer/bits/GraphicsChars.java
new file mode 100644 (file)
index 0000000..58be231
--- /dev/null
@@ -0,0 +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() {
+    }
+
+}
diff --git a/src/jexer/bits/MnemonicString.java b/src/jexer/bits/MnemonicString.java
new file mode 100644 (file)
index 0000000..58575b5
--- /dev/null
@@ -0,0 +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;
+    }
+
+}
diff --git a/src/jexer/bits/StringUtils.java b/src/jexer/bits/StringUtils.java
new file mode 100644 (file)
index 0000000..fffce20
--- /dev/null
@@ -0,0 +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));
+    }
+
+}
diff --git a/src/jexer/bits/package-info.java b/src/jexer/bits/package-info.java
new file mode 100644 (file)
index 0000000..cffe10e
--- /dev/null
@@ -0,0 +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;
diff --git a/src/jexer/demos/Demo1.java b/src/jexer/demos/Demo1.java
new file mode 100644 (file)
index 0000000..97088d2
--- /dev/null
@@ -0,0 +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();
+        }
+    }
+
+}
diff --git a/src/jexer/demos/Demo2.java b/src/jexer/demos/Demo2.java
new file mode 100644 (file)
index 0000000..2db03ce
--- /dev/null
@@ -0,0 +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
+                }
+            }
+        }
+    }
+
+}
diff --git a/src/jexer/demos/Demo2.properties b/src/jexer/demos/Demo2.properties
new file mode 100644 (file)
index 0000000..fa2b98f
--- /dev/null
@@ -0,0 +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}
diff --git a/src/jexer/demos/Demo3.java b/src/jexer/demos/Demo3.java
new file mode 100644 (file)
index 0000000..f370f8f
--- /dev/null
@@ -0,0 +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();
+        }
+    }
+
+}
diff --git a/src/jexer/demos/Demo4.java b/src/jexer/demos/Demo4.java
new file mode 100644 (file)
index 0000000..edbc2c0
--- /dev/null
@@ -0,0 +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();
+        }
+    }
+
+}
diff --git a/src/jexer/demos/Demo5.java b/src/jexer/demos/Demo5.java
new file mode 100644 (file)
index 0000000..e63abc1
--- /dev/null
@@ -0,0 +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();
+        }
+    }
+
+}
diff --git a/src/jexer/demos/Demo5.properties b/src/jexer/demos/Demo5.properties
new file mode 100644 (file)
index 0000000..56b419d
--- /dev/null
@@ -0,0 +1 @@
+frameTitle=Two Jexer Apps In One Swing UI
diff --git a/src/jexer/demos/Demo6.java b/src/jexer/demos/Demo6.java
new file mode 100644 (file)
index 0000000..db0b5c9
--- /dev/null
@@ -0,0 +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();
+        }
+    }
+
+}
diff --git a/src/jexer/demos/Demo6.properties b/src/jexer/demos/Demo6.properties
new file mode 100644 (file)
index 0000000..450829a
--- /dev/null
@@ -0,0 +1 @@
+monitorWindow=Monitor Window
diff --git a/src/jexer/demos/Demo7.java b/src/jexer/demos/Demo7.java
new file mode 100644 (file)
index 0000000..5f92347
--- /dev/null
@@ -0,0 +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();
+    }
+
+}
diff --git a/src/jexer/demos/Demo7.properties b/src/jexer/demos/Demo7.properties
new file mode 100644 (file)
index 0000000..e6fd7ee
--- /dev/null
@@ -0,0 +1 @@
+windowTitle=BoxLayoutManager Demo
diff --git a/src/jexer/demos/DemoApplication.java b/src/jexer/demos/DemoApplication.java
new file mode 100644 (file)
index 0000000..3e4cbe9
--- /dev/null
@@ -0,0 +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();
+    }
+
+}
diff --git a/src/jexer/demos/DemoApplication.properties b/src/jexer/demos/DemoApplication.properties
new file mode 100644 (file)
index 0000000..95d8603
--- /dev/null
@@ -0,0 +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
diff --git a/src/jexer/demos/DemoCheckBoxWindow.java b/src/jexer/demos/DemoCheckBoxWindow.java
new file mode 100644 (file)
index 0000000..fda7bd7
--- /dev/null
@@ -0,0 +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"));
+    }
+
+}
diff --git a/src/jexer/demos/DemoCheckBoxWindow.properties b/src/jexer/demos/DemoCheckBoxWindow.properties
new file mode 100644 (file)
index 0000000..61210ce
--- /dev/null
@@ -0,0 +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
diff --git a/src/jexer/demos/DemoEditorWindow.java b/src/jexer/demos/DemoEditorWindow.java
new file mode 100644 (file)
index 0000000..87798fb
--- /dev/null
@@ -0,0 +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);
+        }
+    }
+
+}
diff --git a/src/jexer/demos/DemoEditorWindow.properties b/src/jexer/demos/DemoEditorWindow.properties
new file mode 100644 (file)
index 0000000..3fa3212
--- /dev/null
@@ -0,0 +1,6 @@
+windowTitle=Editor
+
+statusBar=Editable text demo window
+statusBarHelp=Help
+statusBarShell=Shell
+statusBarExit=Exit
diff --git a/src/jexer/demos/DemoMainWindow.java b/src/jexer/demos/DemoMainWindow.java
new file mode 100644 (file)
index 0000000..8f77448
--- /dev/null
@@ -0,0 +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);
+    }
+
+}
diff --git a/src/jexer/demos/DemoMainWindow.properties b/src/jexer/demos/DemoMainWindow.properties
new file mode 100644 (file)
index 0000000..dba1cb0
--- /dev/null
@@ -0,0 +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
diff --git a/src/jexer/demos/DemoMsgBoxWindow.java b/src/jexer/demos/DemoMsgBoxWindow.java
new file mode 100644 (file)
index 0000000..0485f51
--- /dev/null
@@ -0,0 +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"));
+    }
+}
diff --git a/src/jexer/demos/DemoMsgBoxWindow.properties b/src/jexer/demos/DemoMsgBoxWindow.properties
new file mode 100644 (file)
index 0000000..47a858a
--- /dev/null
@@ -0,0 +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
diff --git a/src/jexer/demos/DemoTableWindow.java b/src/jexer/demos/DemoTableWindow.java
new file mode 100644 (file)
index 0000000..85da32a
--- /dev/null
@@ -0,0 +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);
+        }
+    }
+
+}
diff --git a/src/jexer/demos/DemoTableWindow.properties b/src/jexer/demos/DemoTableWindow.properties
new file mode 100644 (file)
index 0000000..ecc9ec5
--- /dev/null
@@ -0,0 +1,6 @@
+windowTitle=Table
+
+statusBar=Table datagrid demo window
+statusBarHelp=Help
+statusBarShell=Shell
+statusBarExit=Exit
diff --git a/src/jexer/demos/DemoTextFieldWindow.java b/src/jexer/demos/DemoTextFieldWindow.java
new file mode 100644 (file)
index 0000000..2c6116a
--- /dev/null
@@ -0,0 +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"));
+    }
+
+}
diff --git a/src/jexer/demos/DemoTextFieldWindow.properties b/src/jexer/demos/DemoTextFieldWindow.properties
new file mode 100644 (file)
index 0000000..5b42990
--- /dev/null
@@ -0,0 +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
diff --git a/src/jexer/demos/DemoTextWindow.java b/src/jexer/demos/DemoTextWindow.java
new file mode 100644 (file)
index 0000000..7490886
--- /dev/null
@@ -0,0 +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);
+    }
+
+}
diff --git a/src/jexer/demos/DemoTextWindow.properties b/src/jexer/demos/DemoTextWindow.properties
new file mode 100644 (file)
index 0000000..873a56f
--- /dev/null
@@ -0,0 +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
diff --git a/src/jexer/demos/DemoTreeViewWindow.java b/src/jexer/demos/DemoTreeViewWindow.java
new file mode 100644 (file)
index 0000000..4798951
--- /dev/null
@@ -0,0 +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);
+        }
+    }
+
+}
diff --git a/src/jexer/demos/DemoTreeViewWindow.properties b/src/jexer/demos/DemoTreeViewWindow.properties
new file mode 100644 (file)
index 0000000..d63b24e
--- /dev/null
@@ -0,0 +1,7 @@
+windowTitle=Tree View
+
+statusBar=Treeview demonstration
+statusBarHelp=Help
+statusBarShell=Shell
+statusBarOpen=Open
+statusBarExit=Exit
diff --git a/src/jexer/demos/DesktopDemo.java b/src/jexer/demos/DesktopDemo.java
new file mode 100644 (file)
index 0000000..520f5b0
--- /dev/null
@@ -0,0 +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();
+        }
+    }
+
+}
diff --git a/src/jexer/demos/DesktopDemoApplication.java b/src/jexer/demos/DesktopDemoApplication.java
new file mode 100644 (file)
index 0000000..73d0c5f
--- /dev/null
@@ -0,0 +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);
+                }
+            }
+        );
+    }
+
+}
diff --git a/src/jexer/demos/DesktopDemoApplication.properties b/src/jexer/demos/DesktopDemoApplication.properties
new file mode 100644 (file)
index 0000000..85f7435
--- /dev/null
@@ -0,0 +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
diff --git a/src/jexer/demos/package-info.java b/src/jexer/demos/package-info.java
new file mode 100644 (file)
index 0000000..1305cdd
--- /dev/null
@@ -0,0 +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;
diff --git a/src/jexer/event/TCommandEvent.java b/src/jexer/event/TCommandEvent.java
new file mode 100644 (file)
index 0000000..60f6385
--- /dev/null
@@ -0,0 +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;
+    }
+
+}
diff --git a/src/jexer/event/TInputEvent.java b/src/jexer/event/TInputEvent.java
new file mode 100644 (file)
index 0000000..220512f
--- /dev/null
@@ -0,0 +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;
+    }
+
+}
diff --git a/src/jexer/event/TKeypressEvent.java b/src/jexer/event/TKeypressEvent.java
new file mode 100644 (file)
index 0000000..79b28f2
--- /dev/null
@@ -0,0 +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;
+    }
+
+}
diff --git a/src/jexer/event/TMenuEvent.java b/src/jexer/event/TMenuEvent.java
new file mode 100644 (file)
index 0000000..e2ff7c7
--- /dev/null
@@ -0,0 +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;
+    }
+
+}
diff --git a/src/jexer/event/TMouseEvent.java b/src/jexer/event/TMouseEvent.java
new file mode 100644 (file)
index 0000000..496d8bc
--- /dev/null
@@ -0,0 +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);
+    }
+
+}
diff --git a/src/jexer/event/TResizeEvent.java b/src/jexer/event/TResizeEvent.java
new file mode 100644 (file)
index 0000000..ff95710
--- /dev/null
@@ -0,0 +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);
+    }
+
+}
diff --git a/src/jexer/event/package-info.java b/src/jexer/event/package-info.java
new file mode 100644 (file)
index 0000000..e4541a3
--- /dev/null
@@ -0,0 +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;
diff --git a/src/jexer/io/ReadTimeoutException.java b/src/jexer/io/ReadTimeoutException.java
new file mode 100644 (file)
index 0000000..8c6371e
--- /dev/null
@@ -0,0 +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);
+    }
+}
diff --git a/src/jexer/io/TimeoutInputStream.java b/src/jexer/io/TimeoutInputStream.java
new file mode 100644 (file)
index 0000000..3d8cdb0
--- /dev/null
@@ -0,0 +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;
+    }
+
+}
diff --git a/src/jexer/io/package-info.java b/src/jexer/io/package-info.java
new file mode 100644 (file)
index 0000000..37ad2bb
--- /dev/null
@@ -0,0 +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;
diff --git a/src/jexer/layout/BoxLayoutManager.java b/src/jexer/layout/BoxLayoutManager.java
new file mode 100644 (file)
index 0000000..057127f
--- /dev/null
@@ -0,0 +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);
+        }
+    }
+
+}
diff --git a/src/jexer/layout/LayoutManager.java b/src/jexer/layout/LayoutManager.java
new file mode 100644 (file)
index 0000000..5dbd1e8
--- /dev/null
@@ -0,0 +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);
+
+}
diff --git a/src/jexer/layout/StretchLayoutManager.java b/src/jexer/layout/StretchLayoutManager.java
new file mode 100644 (file)
index 0000000..ee2bf5a
--- /dev/null
@@ -0,0 +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));
+        }
+    }
+
+}
diff --git a/src/jexer/layout/package-info.java b/src/jexer/layout/package-info.java
new file mode 100644 (file)
index 0000000..69887dd
--- /dev/null
@@ -0,0 +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;
diff --git a/src/jexer/menu/TMenu.java b/src/jexer/menu/TMenu.java
new file mode 100644 (file)
index 0000000..6d746df
--- /dev/null
@@ -0,0 +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;
+    }
+
+}
diff --git a/src/jexer/menu/TMenu.properties b/src/jexer/menu/TMenu.properties
new file mode 100644 (file)
index 0000000..4a0f8e6
--- /dev/null
@@ -0,0 +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...
+
diff --git a/src/jexer/menu/TMenuItem.java b/src/jexer/menu/TMenuItem.java
new file mode 100644 (file)
index 0000000..d9dfc2a
--- /dev/null
@@ -0,0 +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;
+        }
+    }
+
+}
diff --git a/src/jexer/menu/TMenuSeparator.java b/src/jexer/menu/TMenuSeparator.java
new file mode 100644 (file)
index 0000000..0528e5d
--- /dev/null
@@ -0,0 +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);
+    }
+
+}
diff --git a/src/jexer/menu/TSubMenu.java b/src/jexer/menu/TSubMenu.java
new file mode 100644 (file)
index 0000000..e285c5a
--- /dev/null
@@ -0,0 +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);
+    }
+
+}
diff --git a/src/jexer/menu/package-info.java b/src/jexer/menu/package-info.java
new file mode 100644 (file)
index 0000000..2c10393
--- /dev/null
@@ -0,0 +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;
diff --git a/src/jexer/net/TelnetInputStream.java b/src/jexer/net/TelnetInputStream.java
new file mode 100644 (file)
index 0000000..be3ab50
--- /dev/null
@@ -0,0 +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;
+    }
+
+}
diff --git a/src/jexer/net/TelnetOutputStream.java b/src/jexer/net/TelnetOutputStream.java
new file mode 100644 (file)
index 0000000..6e7536a
--- /dev/null
@@ -0,0 +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);
+        }
+    }
+
+}
diff --git a/src/jexer/net/TelnetServerSocket.java b/src/jexer/net/TelnetServerSocket.java
new file mode 100644 (file)
index 0000000..3c5b307
--- /dev/null
@@ -0,0 +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;
+    }
+
+}
diff --git a/src/jexer/net/TelnetSocket.java b/src/jexer/net/TelnetSocket.java
new file mode 100644 (file)
index 0000000..ac8a278
--- /dev/null
@@ -0,0 +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);
+    }
+
+}
diff --git a/src/jexer/net/package-info.java b/src/jexer/net/package-info.java
new file mode 100644 (file)
index 0000000..5d738fb
--- /dev/null
@@ -0,0 +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;
diff --git a/src/jexer/package-info.java b/src/jexer/package-info.java
new file mode 100644 (file)
index 0000000..300f973
--- /dev/null
@@ -0,0 +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;
diff --git a/src/jexer/resources/jexer_logo_128.png b/src/jexer/resources/jexer_logo_128.png
new file mode 100644 (file)
index 0000000..5c3a813
Binary files /dev/null and b/src/jexer/resources/jexer_logo_128.png differ
diff --git a/src/jexer/resources/terminus-ttf-4.39/COPYING b/src/jexer/resources/terminus-ttf-4.39/COPYING
new file mode 100644 (file)
index 0000000..c964194
--- /dev/null
@@ -0,0 +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
diff --git a/src/jexer/resources/terminus-ttf-4.39/TerminusTTF-4.39.ttf b/src/jexer/resources/terminus-ttf-4.39/TerminusTTF-4.39.ttf
new file mode 100644 (file)
index 0000000..f4bb6b2
Binary files /dev/null and b/src/jexer/resources/terminus-ttf-4.39/TerminusTTF-4.39.ttf differ
diff --git a/src/jexer/resources/terminus-ttf-4.39/TerminusTTF-Bold-4.39.ttf b/src/jexer/resources/terminus-ttf-4.39/TerminusTTF-Bold-4.39.ttf
new file mode 100644 (file)
index 0000000..06700de
Binary files /dev/null and b/src/jexer/resources/terminus-ttf-4.39/TerminusTTF-Bold-4.39.ttf differ
diff --git a/src/jexer/resources/terminus-ttf-4.39/TerminusTTF-Italic-4.39.ttf b/src/jexer/resources/terminus-ttf-4.39/TerminusTTF-Italic-4.39.ttf
new file mode 100644 (file)
index 0000000..e80d0c0
Binary files /dev/null and b/src/jexer/resources/terminus-ttf-4.39/TerminusTTF-Italic-4.39.ttf differ
diff --git a/src/jexer/teditor/Document.java b/src/jexer/teditor/Document.java
new file mode 100644 (file)
index 0000000..2abfef6
--- /dev/null
@@ -0,0 +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();
+    }
+
+}
diff --git a/src/jexer/teditor/Highlighter.java b/src/jexer/teditor/Highlighter.java
new file mode 100644 (file)
index 0000000..a484194
--- /dev/null
@@ -0,0 +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);
+        }
+
+    }
+
+}
diff --git a/src/jexer/teditor/Line.java b/src/jexer/teditor/Line.java
new file mode 100644 (file)
index 0000000..7cd5feb
--- /dev/null
@@ -0,0 +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());
+    }
+
+}
diff --git a/src/jexer/teditor/Word.java b/src/jexer/teditor/Word.java
new file mode 100644 (file)
index 0000000..eada29c
--- /dev/null
@@ -0,0 +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;
+    }
+
+}
diff --git a/src/jexer/teditor/package-info.java b/src/jexer/teditor/package-info.java
new file mode 100644 (file)
index 0000000..8bf5199
--- /dev/null
@@ -0,0 +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;
diff --git a/src/jexer/tterminal/DECCharacterSets.java b/src/jexer/tterminal/DECCharacterSets.java
new file mode 100644 (file)
index 0000000..bca81bb
--- /dev/null
@@ -0,0 +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() {
+    }
+
+}
diff --git a/src/jexer/tterminal/DisplayLine.java b/src/jexer/tterminal/DisplayLine.java
new file mode 100644 (file)
index 0000000..06a05a3
--- /dev/null
@@ -0,0 +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);
+    }
+
+}
diff --git a/src/jexer/tterminal/DisplayListener.java b/src/jexer/tterminal/DisplayListener.java
new file mode 100644 (file)
index 0000000..d0c9e2d
--- /dev/null
@@ -0,0 +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();
+
+}
diff --git a/src/jexer/tterminal/ECMA48.java b/src/jexer/tterminal/ECMA48.java
new file mode 100644 (file)
index 0000000..1d34811
--- /dev/null
@@ -0,0 +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);
+        }
+
+    }
+
+}
diff --git a/src/jexer/tterminal/Sixel.java b/src/jexer/tterminal/Sixel.java
new file mode 100644 (file)
index 0000000..a4c00fc
--- /dev/null
@@ -0,0 +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;
+
+        }
+
+    }
+
+}
diff --git a/src/jexer/tterminal/package-info.java b/src/jexer/tterminal/package-info.java
new file mode 100644 (file)
index 0000000..b92d153
--- /dev/null
@@ -0,0 +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;
diff --git a/src/jexer/ttree/TDirectoryTreeItem.java b/src/jexer/ttree/TDirectoryTreeItem.java
new file mode 100644 (file)
index 0000000..9bdec01
--- /dev/null
@@ -0,0 +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());
+    }
+
+}
diff --git a/src/jexer/ttree/TTreeItem.java b/src/jexer/ttree/TTreeItem.java
new file mode 100644 (file)
index 0000000..44c408b
--- /dev/null
@@ -0,0 +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();
+            }
+        }
+    }
+
+}
diff --git a/src/jexer/ttree/TTreeView.java b/src/jexer/ttree/TTreeView.java
new file mode 100644 (file)
index 0000000..22f72ca
--- /dev/null
@@ -0,0 +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());
+        }
+
+    }
+
+}
diff --git a/src/jexer/ttree/TTreeViewWidget.java b/src/jexer/ttree/TTreeViewWidget.java
new file mode 100644 (file)
index 0000000..080a200
--- /dev/null
@@ -0,0 +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();
+    }
+
+}
diff --git a/src/jexer/ttree/TTreeViewWindow.java b/src/jexer/ttree/TTreeViewWindow.java
new file mode 100644 (file)
index 0000000..f418383
--- /dev/null
@@ -0,0 +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();
+    }
+
+}
diff --git a/src/jexer/ttree/package-info.java b/src/jexer/ttree/package-info.java
new file mode 100644 (file)
index 0000000..1e1fdfd
--- /dev/null
@@ -0,0 +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;