Merge branch 'upstream' into subtree
authorNiki Roo <niki@nikiroo.be>
Thu, 2 Jan 2020 15:04:07 +0000 (16:04 +0100)
committerNiki Roo <niki@nikiroo.be>
Thu, 2 Jan 2020 15:04:07 +0000 (16:04 +0100)
50 files changed:
1  2 
TApplication.java
TApplication.properties
TEditorWidget.java
TEditorWindow.java
TExceptionDialog.java
TExceptionDialog.properties
TField.java
TImage.java
TKeypress.java
TList.java
TPasswordField.java
TRadioButton.java
TRadioGroup.java
TSplitPane.java
TTableWidget.java
TTableWindow.java
TTerminalWidget.java
TTerminalWindow.java
TTerminalWindow.properties
TText.java
TWidget.java
TWindow.java
backend/ECMA48Terminal.java
backend/GlyphMaker.java
backend/LogicalScreen.java
backend/MultiScreen.java
backend/Screen.java
backend/SwingComponent.java
backend/SwingTerminal.java
bits/Cell.java
bits/CellAttributes.java
bits/ColorTheme.java
bits/StringUtils.java
demos/Demo6.java
demos/DemoCheckBoxWindow.java
event/TMouseEvent.java
io/TimeoutInputStream.java
layout/StretchLayoutManager.java
menu/TMenu.java
menu/TMenu.properties
menu/TMenuItem.java
menu/TSubMenu.java
teditor/Document.java
teditor/Highlighter.java
teditor/Line.java
teditor/Word.java
tterminal/DisplayLine.java
tterminal/ECMA48.java
tterminal/Sixel.java
ttree/TTreeViewWidget.java

diff --combined TApplication.java
index 9d27c10f5420052103cee046baae697ca8c2bd6e,28e35091ded6e1ef006190574e945c0426c41057..28e35091ded6e1ef006190574e945c0426c41057
@@@ -29,6 -29,7 +29,7 @@@
  package jexer;
  
  import java.io.File;
+ import java.io.FileInputStream;
  import java.io.InputStream;
  import java.io.IOException;
  import java.io.OutputStream;
@@@ -47,6 -48,7 +48,7 @@@ import java.util.ResourceBundle
  
  import jexer.bits.Cell;
  import jexer.bits.CellAttributes;
+ import jexer.bits.Clipboard;
  import jexer.bits.ColorTheme;
  import jexer.bits.StringUtils;
  import jexer.event.TCommandEvent;
@@@ -61,6 -63,8 +63,8 @@@ import jexer.backend.Screen
  import jexer.backend.SwingBackend;
  import jexer.backend.ECMA48Backend;
  import jexer.backend.TWindowBackend;
+ import jexer.help.HelpFile;
+ import jexer.help.Topic;
  import jexer.menu.TMenu;
  import jexer.menu.TMenuItem;
  import jexer.menu.TSubMenu;
@@@ -148,6 -152,11 +152,11 @@@ public class TApplication implements Ru
       */
      private Backend backend;
  
+     /**
+      * The clipboard for copy and paste.
+      */
+     private Clipboard clipboard = new Clipboard();
      /**
       * Actual mouse coordinate X.
       */
       */
      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 List<TWindow> windows;
  
-     /**
-      * The currently acive window.
-      */
-     private TWindow activeWindow = null;
      /**
       * Timers that are being ticked.
       */
       */
      private long screenResizeTime = 0;
  
+     /**
+      * If true, screen selection is a rectangle.
+      */
+     private boolean screenSelectionRectangle = false;
+     /**
+      * If true, the mouse is dragging a screen selection.
+      */
+     private boolean inScreenSelection = false;
+     /**
+      * Screen selection starting X.
+      */
+     private int screenSelectionX0;
+     /**
+      * Screen selection starting Y.
+      */
+     private int screenSelectionY0;
+     /**
+      * Screen selection ending X.
+      */
+     private int screenSelectionX1;
+     /**
+      * Screen selection ending Y.
+      */
+     private int screenSelectionY1;
+     /**
+      * The help file data.  Note package private access.
+      */
+     HelpFile helpFile;
+     /**
+      * The stack of help topics.  Note package private access.
+      */
+     ArrayList<Topic> helpTopics = new ArrayList<Topic>();
      /**
       * WidgetEventHandler is the main event consumer loop.  There are at most
       * two such threads in existence: the primary for normal case and a
              }
          }
  
+         // Load the help system
+         invokeLater(new Runnable() {
+             /*
+              * This isn't the best solution.  But basically if a TApplication
+              * subclass constructor throws and needs to use TExceptionDialog,
+              * it may end up at the bottom of the window stack with a bunch
+              * of modal windows on top of it if said constructors spawn their
+              * windows also via invokeLater().  But if they don't do that,
+              * and instead just conventionally construct their windows, then
+              * this exception dialog will end up on top where it should be.
+              */
+             public void run() {
+                 try {
+                     ClassLoader loader = Thread.currentThread().getContextClassLoader();
+                     helpFile = new HelpFile();
+                     helpFile.load(loader.getResourceAsStream("help.xml"));
+                 } catch (Exception e) {
+                     new TExceptionDialog(TApplication.this, e);
+                 }
+             }
+         });
      }
  
      // ------------------------------------------------------------------------
              return true;
          }
  
+         if (command.equals(cmHelp)) {
+             if (getActiveWindow() != null) {
+                 new THelpWindow(this, getActiveWindow().getHelpTopic());
+             } else {
+                 new THelpWindow(this);
+             }
+             return true;
+         }
          if (command.equals(cmShell)) {
              openTerminal(0, 0, TWindow.RESIZABLE);
              return true;
              return true;
          }
  
+         if (menu.getId() == TMenu.MID_HELP_HELP) {
+             new THelpWindow(this, THelpWindow.HELP_HELP);
+             return true;
+         }
+         if (menu.getId() == TMenu.MID_HELP_CONTENTS) {
+             new THelpWindow(this, helpFile.getTableOfContents());
+             return true;
+         }
+         if (menu.getId() == TMenu.MID_HELP_INDEX) {
+             new THelpWindow(this, helpFile.getIndex());
+             return true;
+         }
+         if (menu.getId() == TMenu.MID_HELP_SEARCH) {
+             TInputBox inputBox = inputBox(i18n.
+                 getString("searchHelpInputBoxTitle"),
+                 i18n.getString("searchHelpInputBoxCaption"), "",
+                 TInputBox.Type.OKCANCEL);
+             if (inputBox.isOk()) {
+                 new THelpWindow(this,
+                     helpFile.getSearchResults(inputBox.getText()));
+             }
+             return true;
+         }
+         if (menu.getId() == TMenu.MID_HELP_PREVIOUS) {
+             if (helpTopics.size() > 1) {
+                 Topic previous = helpTopics.remove(helpTopics.size() - 2);
+                 helpTopics.remove(helpTopics.size() - 1);
+                 new THelpWindow(this, previous);
+             } else {
+                 new THelpWindow(this, helpFile.getTableOfContents());
+             }
+             return true;
+         }
+         if (menu.getId() == TMenu.MID_HELP_ACTIVE_FILE) {
+             try {
+                 List<String> filters = new ArrayList<String>();
+                 filters.add("^.*\\.[Xx][Mm][Ll]$");
+                 String filename = fileOpenBox(".", TFileOpenBox.Type.OPEN,
+                     filters);
+                 if (filename != null) {
+                     helpTopics = new ArrayList<Topic>();
+                     helpFile = new HelpFile();
+                     helpFile.load(new FileInputStream(filename));
+                 }
+             } catch (Exception e) {
+                 // Show this exception to the user.
+                 new TExceptionDialog(this, e);
+             }
+             return true;
+         }
          if (menu.getId() == TMenu.MID_SHELL) {
              openTerminal(0, 0, TWindow.RESIZABLE);
              return true;
              new TFontChooserWindow(this);
              return true;
          }
+         if (menu.getId() == TMenu.MID_CUT) {
+             postMenuEvent(new TCommandEvent(cmCut));
+             return true;
+         }
+         if (menu.getId() == TMenu.MID_COPY) {
+             postMenuEvent(new TCommandEvent(cmCopy));
+             return true;
+         }
+         if (menu.getId() == TMenu.MID_PASTE) {
+             postMenuEvent(new TCommandEvent(cmPaste));
+             return true;
+         }
+         if (menu.getId() == TMenu.MID_CLEAR) {
+             postMenuEvent(new TCommandEvent(cmClear));
+             return true;
+         }
          return false;
      }
  
                  Thread.currentThread() + " finishEventProcessing()\n");
          }
  
+         // See if we need to enable/disable the edit menu.
+         EditMenuUser widget = null;
+         if (activeMenu == null) {
+             TWindow activeWindow = getActiveWindow();
+             if (activeWindow != null) {
+                 if (activeWindow.getActiveChild() instanceof EditMenuUser) {
+                     widget = (EditMenuUser) activeWindow.getActiveChild();
+                 }
+             } else if (desktop != null) {
+                 if (desktop.getActiveChild() instanceof EditMenuUser) {
+                     widget = (EditMenuUser) desktop.getActiveChild();
+                 }
+             }
+             if (widget == null) {
+                 disableMenuItem(TMenu.MID_CUT);
+                 disableMenuItem(TMenu.MID_COPY);
+                 disableMenuItem(TMenu.MID_PASTE);
+                 disableMenuItem(TMenu.MID_CLEAR);
+             } else {
+                 if (widget.isEditMenuCut()) {
+                     enableMenuItem(TMenu.MID_CUT);
+                 } else {
+                     disableMenuItem(TMenu.MID_CUT);
+                 }
+                 if (widget.isEditMenuCopy()) {
+                     enableMenuItem(TMenu.MID_COPY);
+                 } else {
+                     disableMenuItem(TMenu.MID_COPY);
+                 }
+                 if (widget.isEditMenuPaste()) {
+                     enableMenuItem(TMenu.MID_PASTE);
+                 } else {
+                     disableMenuItem(TMenu.MID_PASTE);
+                 }
+                 if (widget.isEditMenuClear()) {
+                     enableMenuItem(TMenu.MID_CLEAR);
+                 } else {
+                     disableMenuItem(TMenu.MID_CLEAR);
+                 }
+             }
+         }
          // Process timers and call doIdle()'s
          doIdle();
  
                      }
                      mouseX = 0;
                      mouseY = 0;
-                     oldMouseX = 0;
-                     oldMouseY = 0;
                  }
                  if (desktop != null) {
                      desktop.setDimensions(0, desktopTop, resize.getWidth(),
              typingHidMouse = false;
  
              TMouseEvent mouse = (TMouseEvent) event;
+             if (mouse.isMouse1() && (mouse.isShift() || mouse.isCtrl())) {
+                 // Screen selection.
+                 if (inScreenSelection) {
+                     screenSelectionX1 = mouse.getX();
+                     screenSelectionY1 = mouse.getY();
+                 } else {
+                     inScreenSelection = true;
+                     screenSelectionX0 = mouse.getX();
+                     screenSelectionY0 = mouse.getY();
+                     screenSelectionX1 = mouse.getX();
+                     screenSelectionY1 = mouse.getY();
+                     screenSelectionRectangle = mouse.isCtrl();
+                 }
+             } else {
+                 if (inScreenSelection) {
+                     getScreen().copySelection(clipboard, screenSelectionX0,
+                         screenSelectionY0, screenSelectionX1, screenSelectionY1,
+                         screenSelectionRectangle);
+                 }
+                 inScreenSelection = false;
+             }
              if ((mouseX != mouse.getX()) || (mouseY != mouse.getY())) {
-                 oldMouseX = mouseX;
-                 oldMouseY = mouseY;
                  mouseX = mouse.getX();
                  mouseY = mouse.getY();
              } else {
                              mouse.getAbsoluteX(), mouse.getAbsoluteY(),
                              mouse.isMouse1(), mouse.isMouse2(),
                              mouse.isMouse3(),
-                             mouse.isMouseWheelUp(), mouse.isMouseWheelDown());
+                             mouse.isMouseWheelUp(), mouse.isMouseWheelDown(),
+                             mouse.isAlt(), mouse.isCtrl(), mouse.isShift());
  
                      } else {
                          // The first click of a potential double-click.
              // shortcutted by the active window, and if so dispatch the menu
              // event.
              boolean windowWillShortcut = false;
+             TWindow activeWindow = getActiveWindow();
              if (activeWindow != null) {
                  assert (activeWindow.isShown());
                  if (activeWindow.isShortcutKeypress(keypress.getKey())) {
  
          // Dispatch events to the active window -------------------------------
          boolean dispatchToDesktop = true;
-         TWindow window = activeWindow;
+         TWindow window = getActiveWindow();
          if (window != null) {
              assert (window.isActive());
              assert (window.isShown());
  
              TMouseEvent mouse = (TMouseEvent) event;
              if ((mouseX != mouse.getX()) || (mouseY != mouse.getY())) {
-                 oldMouseX = mouseX;
-                 oldMouseY = mouseY;
                  mouseX = mouse.getX();
                  mouseY = mouse.getY();
              } else {
                              mouse.getAbsoluteX(), mouse.getAbsoluteY(),
                              mouse.isMouse1(), mouse.isMouse2(),
                              mouse.isMouse3(),
-                             mouse.isMouseWheelUp(), mouse.isMouseWheelDown());
+                             mouse.isMouseWheelUp(), mouse.isMouseWheelDown(),
+                             mouse.isAlt(), mouse.isCtrl(), mouse.isShift());
  
                      } else {
                          // The first click of a potential double-click.
              desktop.onIdle();
          }
  
-         // Run any invokeLaters
+         // Run any invokeLaters.  We make a copy, and run that, because one
+         // of these Runnables might add call TApplication.invokeLater().
+         List<Runnable> invokes = new ArrayList<Runnable>();
          synchronized (invokeLaters) {
-             for (Runnable invoke: invokeLaters) {
-                 invoke.run();
-             }
+             invokes.addAll(invokeLaters);
              invokeLaters.clear();
          }
+         for (Runnable invoke: invokes) {
+             invoke.run();
+         }
+         doRepaint();
  
      }
  
          return theme;
      }
  
+     /**
+      * Get the clipboard.
+      *
+      * @return the clipboard
+      */
+     public final Clipboard getClipboard() {
+         return clipboard;
+     }
      /**
       * Repaint the screen on the next update.
       */
       * @return the active window, or null if it is not set
       */
      public final TWindow getActiveWindow() {
-         return activeWindow;
+         for (TWindow window: windows) {
+             if (window.isShown() && window.isActive()) {
+                 return window;
+             }
+         }
+         return null;
      }
  
      /**
          String version = getClass().getPackage().getImplementationVersion();
          if (version == null) {
              // This is Java 9+, use a hardcoded string here.
-             version = "0.3.2";
+             version = "1.0.0";
          }
          messageBox(i18n.getString("aboutDialogTitle"),
              MessageFormat.format(i18n.getString("aboutDialogText"), version),
      // ------------------------------------------------------------------------
  
      /**
-      * 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.
+      * Draw the text mouse at position.
       *
       * @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) {
+     private void drawTextMouse(final int x, final int y) {
+         TWindow activeWindow = getActiveWindow();
  
          if (debugThreads) {
-             System.err.printf("%d %s invertCell() %d %d\n",
+             System.err.printf("%d %s drawTextMouse() %d %d\n",
                  System.currentTimeMillis(), Thread.currentThread(), x, y);
  
              if (activeWindow != null) {
              }
          }
  
-         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);
-                 }
-             }
-         }
+         getScreen().invertCell(x, y);
      }
  
      /**
                      }
                  }
  
+                 if (inScreenSelection) {
+                     getScreen().setSelection(screenSelectionX0,
+                         screenSelectionY0, screenSelectionX1, screenSelectionY1,
+                         screenSelectionRectangle);
+                 }
                  if ((textMouse == true) && (typingHidMouse == false)) {
                      // Draw mouse at the new position.
-                     invertCell(mouseX, mouseY);
+                     drawTextMouse(mouseX, mouseY);
                  }
  
                  oldDrawnMouseX = mouseX;
              // Draw the status bar of the top-level window
              TStatusBar statusBar = null;
              if (topLevel != null) {
-                 statusBar = topLevel.getStatusBar();
+                 if (topLevel.isShown()) {
+                     statusBar = topLevel.getStatusBar();
+                 }
              }
              if (statusBar != null) {
                  getScreen().resetClipping();
                  getScreen().unsetImageRow(mouseY);
              }
          }
+         if (inScreenSelection) {
+             getScreen().setSelection(screenSelectionX0, screenSelectionY0,
+                 screenSelectionX1, screenSelectionY1, screenSelectionRectangle);
+         }
          if ((textMouse == true) && (typingHidMouse == false)) {
-             invertCell(mouseX, mouseY);
+             drawTextMouse(mouseX, mouseY);
          }
          oldDrawnMouseX = mouseX;
          oldDrawnMouseY = mouseY;
       *
       * @param window the window to become the new active window
       */
-     public void activateWindow(final TWindow window) {
+     public final void activateWindow(final TWindow window) {
          if (hasWindow(window) == false) {
              /*
               * Someone has a handle to a window I don't have.  Ignore this
              return;
          }
  
-         // Whatever window might be moving/dragging, stop it now.
-         for (TWindow w: windows) {
-             if (w.inMovements()) {
-                 w.stopMovements();
-             }
+         if (modalWindowActive() && !window.isModal()) {
+             // Do not activate a non-modal on top of a modal.
+             return;
          }
  
-         assert (windows.size() > 0);
+         synchronized (windows) {
+             // Whatever window might be moving/dragging, stop it now.
+             for (TWindow w: windows) {
+                 if (w.inMovements()) {
+                     w.stopMovements();
+                 }
+             }
  
-         if (window.isHidden()) {
-             // Unhiding will also activate.
-             showWindow(window);
-             return;
-         }
-         assert (window.isShown());
+             assert (windows.size() > 0);
  
-         if (windows.size() == 1) {
-             assert (window == windows.get(0));
-             if (activeWindow == null) {
-                 activeWindow = window;
-                 window.setZ(0);
-                 activeWindow.setActive(true);
-                 activeWindow.onFocus();
+             if (window.isHidden()) {
+                 // Unhiding will also activate.
+                 showWindow(window);
+                 return;
              }
+             assert (window.isShown());
  
-             assert (window.isActive());
-             assert (activeWindow == window);
-             return;
-         }
+             if (windows.size() == 1) {
+                 assert (window == windows.get(0));
+                 window.setZ(0);
+                 window.setActive(true);
+                 window.onFocus();
+                 return;
+             }
  
-         if (activeWindow == window) {
-             assert (window.isActive());
+             if (getActiveWindow() == window) {
+                 assert (window.isActive());
  
-             // Window is already active, do nothing.
-             return;
-         }
+                 // Window is already active, do nothing.
+                 return;
+             }
  
-         assert (!window.isActive());
-         if (activeWindow != null) {
-             activeWindow.setActive(false);
+             assert (!window.isActive());
  
-             // Increment every window Z that is on top of window
+             window.setZ(-1);
+             Collections.sort(windows);
+             int newZ = 0;
              for (TWindow w: windows) {
-                 if (w == window) {
-                     continue;
-                 }
-                 if (w.getZ() < window.getZ()) {
-                     w.setZ(w.getZ() + 1);
+                 w.setZ(newZ);
+                 newZ++;
+                 if ((w != window) && w.isActive()) {
+                     w.onUnfocus();
                  }
+                 w.setActive(false);
              }
+             window.setActive(true);
+             window.onFocus();
+         } // synchronized (windows)
  
-             // 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;
      }
  
              return;
          }
  
-         // Whatever window might be moving/dragging, stop it now.
-         for (TWindow w: windows) {
-             if (w.inMovements()) {
-                 w.stopMovements();
+         synchronized (windows) {
+             // Whatever window might be moving/dragging, stop it now.
+             for (TWindow w: windows) {
+                 if (w.inMovements()) {
+                     w.stopMovements();
+                 }
              }
-         }
  
-         assert (windows.size() > 0);
+             assert (windows.size() > 0);
  
-         if (!window.hidden) {
-             if (window == activeWindow) {
-                 if (shownWindowCount() > 1) {
-                     switchWindow(true);
-                 } else {
-                     activeWindow = null;
-                     window.setActive(false);
-                     window.onUnfocus();
-                 }
+             if (window.hidden) {
+                 return;
              }
+             window.setActive(false);
              window.hidden = true;
              window.onHide();
-         }
+             TWindow activeWindow = null;
+             for (TWindow w: windows) {
+                 if (w.isShown()) {
+                     activeWindow = w;
+                     break;
+                 }
+             }
+             assert (activeWindow != window);
+             if (activeWindow != null) {
+                 activateWindow(activeWindow);
+             }
+         } // synchronized (windows)
      }
  
      /**
              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.
+      * Close window.
       *
       * @param window the window to remove
       */
          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.stopMovements();
              window.onUnfocus();
              windows.remove(window);
              Collections.sort(windows);
-             activeWindow = null;
-             int newZ = 0;
-             boolean foundNextWindow = false;
  
+             TWindow nextWindow = null;
+             int newZ = 0;
              for (TWindow w: windows) {
+                 w.stopMovements();
                  w.setZ(newZ);
                  newZ++;
  
                  if (w.isHidden()) {
                      continue;
                  }
-                 if (foundNextWindow == false) {
-                     foundNextWindow = true;
-                     w.setActive(true);
-                     w.onFocus();
-                     assert (activeWindow == null);
-                     activeWindow = w;
-                     continue;
+                 if (nextWindow == null) {
+                     nextWindow = w;
+                 } else {
+                     if (w.isActive()) {
+                         w.setActive(false);
+                         w.onUnfocus();
+                     }
                  }
+             }
  
-                 if (w.isActive()) {
-                     w.setActive(false);
-                     w.onUnfocus();
-                 }
+             if (nextWindow != null) {
+                 nextWindow.setActive(true);
+                 nextWindow.onFocus();
              }
-         }
+         } // synchronized (windows)
  
          // Perform window cleanup
          window.onClose();
              synchronized (secondaryEventHandler) {
                  secondaryEventHandler.notify();
              }
-         }
+         } // synchronized (windows)
  
          // Permit desktop to be active if it is the only thing left.
          if (desktop != null) {
          if (shownWindowCount() < 2) {
              return;
          }
-         assert (activeWindow != null);
+         if (modalWindowActive()) {
+             // Do not switch if a window is modal
+             return;
+         }
  
          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;
+             TWindow window = windows.get(0);
+             do {
+                 assert (window != null);
+                 if (forward) {
+                     window.setZ(windows.size());
                  } else {
-                     assert (!windows.get(0).isActive());
+                     TWindow lastWindow = windows.get(windows.size() - 1);
+                     lastWindow.setZ(-1);
                  }
-             }
-             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;
-                     }
+                 Collections.sort(windows);
+                 int newZ = 0;
+                 for (TWindow w: windows) {
+                     w.setZ(newZ);
+                     newZ++;
                  }
  
-                 if (windows.get(nextWindowI).isShown()) {
-                     activateWindow(windows.get(nextWindowI));
-                     break;
+                 window = windows.get(0);
+             } while (!window.isShown());
+             // The next visible window is now on top.  Renumber the list.
+             for (TWindow w: windows) {
+                 w.stopMovements();
+                 if ((w != window) && w.isActive()) {
+                     assert (w.isShown());
+                     w.setActive(false);
+                     w.onUnfocus();
                  }
              }
-         } // synchronized (windows)
  
+             // Next visible window is on top.
+             assert (window.isShown());
+             window.setActive(true);
+             window.onFocus();
+         } // synchronized (windows)
      }
  
      /**
                      }
                      w.setZ(w.getZ() + 1);
                  }
-             }
-             windows.add(window);
-             if (window.isShown()) {
-                 activeWindow = window;
-                 activeWindow.setZ(0);
-                 activeWindow.setActive(true);
-                 activeWindow.onFocus();
+                 window.setZ(0);
+                 window.setActive(true);
+                 window.onFocus();
+                 windows.add(0, window);
+             } else {
+                 window.setZ(windows.size());
+                 windows.add(window);
              }
  
              if (((window.flags & TWindow.CENTERED) == 0)
          if (desktop != null) {
              desktop.setActive(false);
          }
      }
  
      /**
       * @return true if the active window is overriding the menu
       */
      private boolean overrideMenuWindowActive() {
+         TWindow activeWindow = getActiveWindow();
          if (activeWindow != null) {
              if (activeWindow.hasOverriddenMenu()) {
                  return true;
              || (mouse.getType() == TMouseEvent.Type.MOUSE_DOWN)
          ) {
              synchronized (windows) {
-                 Collections.sort(windows);
                  if (windows.get(0).isModal()) {
                      // Modal windows don't switch
                      return;
                      }
  
                      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();
+                         activateWindow(window);
                          return;
                      }
                  }
       */
      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);
+         editMenu.addDefaultItem(TMenu.MID_UNDO, false);
+         editMenu.addDefaultItem(TMenu.MID_REDO, false);
+         editMenu.addSeparator();
+         editMenu.addDefaultItem(TMenu.MID_CUT, false);
+         editMenu.addDefaultItem(TMenu.MID_COPY, false);
+         editMenu.addDefaultItem(TMenu.MID_PASTE, false);
+         editMenu.addDefaultItem(TMenu.MID_CLEAR, false);
          TStatusBar statusBar = editMenu.newStatusBar(i18n.
              getString("editMenuStatus"));
          statusBar.addShortcutKeypress(kbF1, cmHelp, i18n.getString("Help"));
diff --combined TApplication.properties
index 299c6a3a90501469469dc7a8550a3c8f42cf146d,57f7c595d85e030d7ab56ce724a3817a4d8f2260..57f7c595d85e030d7ab56ce724a3817a4d8f2260
@@@ -25,3 -25,6 +25,6 @@@ exitDialogText=Exit application
  
  aboutDialogTitle=About
  aboutDialogText=Jexer Version {0}
+ searchHelpInputBoxTitle=Search Help Topics
+ searchHelpInputBoxCaption=Search help topics for (regex):
diff --combined TEditorWidget.java
index a694533bf6df0ed3ee4d6e694f0d1b507deab7a3,bea25eda3e74c9c0e2c60c012dfd4e976d1198e4..bea25eda3e74c9c0e2c60c012dfd4e976d1198e4
  package jexer;
  
  import java.io.IOException;
+ import java.util.ArrayList;
+ import java.util.List;
  
  import jexer.bits.CellAttributes;
+ import jexer.bits.StringUtils;
+ import jexer.event.TCommandEvent;
  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.TCommand.*;
  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 {
+ public class TEditorWidget extends TWidget implements EditMenuUser {
  
      // ------------------------------------------------------------------------
      // Constants --------------------------------------------------------------
      /**
       * The document being edited.
       */
-     private Document document;
+     protected Document document;
  
      /**
-      * The default color for the TEditor class.
+      * The default color for the editable text.
       */
      private CellAttributes defaultColor = null;
  
       */
      private int leftColumn = 0;
  
+     /**
+      * If true, the mouse is dragging a selection.
+      */
+     private boolean inSelection = false;
+     /**
+      * Selection starting column.
+      */
+     private int selectionColumn0;
+     /**
+      * Selection starting line.
+      */
+     private int selectionLine0;
+     /**
+      * Selection ending column.
+      */
+     private int selectionColumn1;
+     /**
+      * Selection ending line.
+      */
+     private int selectionLine1;
+     /**
+      * The list of undo/redo states.
+      */
+     private List<SavedState> undoList = new ArrayList<SavedState>();
+     /**
+      * The position in undoList for undo/redo.
+      */
+     private int undoListI = 0;
+     /**
+      * The maximum size of the undo list.
+      */
+     private int undoLevel = 50;
+     /**
+      * The saved state for an undo/redo operation.
+      */
+     private class SavedState {
+         /**
+          * The Document state.
+          */
+         public Document document;
+         /**
+          * The topmost line number in the visible area.  0-based.
+          */
+         public int topLine = 0;
+         /**
+          * The leftmost column number in the visible area.  0-based.
+          */
+         public int leftColumn = 0;
+     }
      // ------------------------------------------------------------------------
      // Constructors -----------------------------------------------------------
      // ------------------------------------------------------------------------
      }
  
      // ------------------------------------------------------------------------
-     // TWidget ----------------------------------------------------------------
+     // Event handlers ---------------------------------------------------------
      // ------------------------------------------------------------------------
  
-     /**
-      * 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.
       *
          }
  
          if (mouse.isMouse1()) {
-             // Set the row and column
+             // Selection.
              int newLine = topLine + mouse.getY();
              int newX = leftColumn + mouse.getX();
+             inSelection = true;
+             if (newLine > document.getLineCount() - 1) {
+                 selectionLine0 = document.getLineCount() - 1;
+             } else {
+                 selectionLine0 = topLine + mouse.getY();
+             }
+             selectionColumn0 = leftColumn + mouse.getX();
+             selectionColumn0 = Math.max(0, Math.min(selectionColumn0,
+                     document.getLine(selectionLine0).getDisplayLength() - 1));
+             selectionColumn1 = selectionColumn0;
+             selectionLine1 = selectionLine0;
+             // Set the row and column
              if (newLine > document.getLineCount() - 1) {
                  // Go to the end
                  document.setLineNumber(document.getLineCount() - 1);
                      setCursorY(mouse.getY());
                  }
                  alignCursor();
+                 if (inSelection) {
+                     selectionColumn1 = document.getCursor();
+                     selectionLine1 = document.getLineNumber();
+                 }
                  return;
              }
  
                  document.setCursor(newX);
                  setCursorX(mouse.getX());
              }
+             if (inSelection) {
+                 selectionColumn1 = document.getCursor();
+                 selectionLine1 = document.getLineNumber();
+             }
+             return;
+         } else {
+             inSelection = false;
+         }
+         // Pass to children
+         super.onMouseDown(mouse);
+     }
+     /**
+      * Handle mouse motion events.
+      *
+      * @param mouse mouse motion event
+      */
+     @Override
+     public void onMouseMotion(final TMouseEvent mouse) {
+         if (mouse.isMouse1()) {
+             // Set the row and column
+             int newLine = topLine + mouse.getY();
+             int newX = leftColumn + mouse.getX();
+             if ((newLine < 0) || (newX < 0)) {
+                 return;
+             }
+             // Selection.
+             if (inSelection) {
+                 selectionColumn1 = newX;
+                 selectionLine1 = newLine;
+             } else {
+                 inSelection = true;
+                 selectionColumn0 = newX;
+                 selectionLine0 = newLine;
+                 selectionColumn1 = selectionColumn0;
+                 selectionLine1 = selectionLine0;
+             }
+             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();
+                 if (inSelection) {
+                     selectionColumn1 = document.getCursor();
+                     selectionLine1 = document.getLineNumber();
+                 }
+                 return;
+             }
+             document.setLineNumber(newLine);
+             setCursorY(mouse.getY());
+             if (newX >= document.getCurrentLine().getDisplayLength()) {
+                 document.end();
+                 alignCursor();
+             } else {
+                 document.setCursor(newX);
+                 setCursorX(mouse.getX());
+             }
+             if (inSelection) {
+                 selectionColumn1 = document.getCursor();
+                 selectionLine1 = document.getLineNumber();
+             }
              return;
          }
  
       */
      @Override
      public void onKeypress(final TKeypressEvent keypress) {
-         if (keypress.equals(kbLeft)) {
+         if (keypress.getKey().isShift()) {
+             if (keypress.equals(kbShiftLeft)
+                 || keypress.equals(kbShiftRight)
+                 || keypress.equals(kbShiftUp)
+                 || keypress.equals(kbShiftDown)
+                 || keypress.equals(kbShiftPgDn)
+                 || keypress.equals(kbShiftPgUp)
+                 || keypress.equals(kbShiftHome)
+                 || keypress.equals(kbShiftEnd)
+             ) {
+                 // Shifted navigation keys enable selection
+                 if (!inSelection) {
+                     inSelection = true;
+                     selectionColumn0 = document.getCursor();
+                     selectionLine0 = document.getLineNumber();
+                     selectionColumn1 = selectionColumn0;
+                     selectionLine1 = selectionLine0;
+                 }
+             }
+         } else {
+             if (keypress.equals(kbLeft)
+                 || keypress.equals(kbRight)
+                 || keypress.equals(kbUp)
+                 || keypress.equals(kbDown)
+                 || keypress.equals(kbPgDn)
+                 || keypress.equals(kbPgUp)
+                 || keypress.equals(kbHome)
+                 || keypress.equals(kbEnd)
+             ) {
+                 // Non-shifted navigation keys disable selection.
+                 inSelection = false;
+             }
+             if ((selectionColumn0 == selectionColumn1)
+                 && (selectionLine0 == selectionLine1)
+             ) {
+                 // The user clicked a spot and started typing.
+                 inSelection = false;
+             }
+         }
+         if (keypress.equals(kbLeft)
+             || keypress.equals(kbShiftLeft)
+         ) {
              document.left();
              alignTopLine(false);
-         } else if (keypress.equals(kbRight)) {
+         } else if (keypress.equals(kbRight)
+             || keypress.equals(kbShiftRight)
+         ) {
              document.right();
              alignTopLine(true);
          } else if (keypress.equals(kbAltLeft)
              || keypress.equals(kbCtrlLeft)
+             || keypress.equals(kbAltShiftLeft)
+             || keypress.equals(kbCtrlShiftLeft)
          ) {
              document.backwardsWord();
              alignTopLine(false);
          } else if (keypress.equals(kbAltRight)
              || keypress.equals(kbCtrlRight)
+             || keypress.equals(kbAltShiftRight)
+             || keypress.equals(kbCtrlShiftRight)
          ) {
              document.forwardsWord();
              alignTopLine(true);
-         } else if (keypress.equals(kbUp)) {
+         } else if (keypress.equals(kbUp)
+             || keypress.equals(kbShiftUp)
+         ) {
              document.up();
              alignTopLine(false);
-         } else if (keypress.equals(kbDown)) {
+         } else if (keypress.equals(kbDown)
+             || keypress.equals(kbShiftDown)
+         ) {
              document.down();
              alignTopLine(true);
-         } else if (keypress.equals(kbPgUp)) {
+         } else if (keypress.equals(kbPgUp)
+             || keypress.equals(kbShiftPgUp)
+         ) {
              document.up(getHeight() - 1);
              alignTopLine(false);
-         } else if (keypress.equals(kbPgDn)) {
+         } else if (keypress.equals(kbPgDn)
+             || keypress.equals(kbShiftPgDn)
+         ) {
              document.down(getHeight() - 1);
              alignTopLine(true);
-         } else if (keypress.equals(kbHome)) {
+         } else if (keypress.equals(kbHome)
+             || keypress.equals(kbShiftHome)
+         ) {
              if (document.home()) {
                  leftColumn = 0;
                  if (leftColumn < 0) {
                  }
                  setCursorX(0);
              }
-         } else if (keypress.equals(kbEnd)) {
+         } else if (keypress.equals(kbEnd)
+             || keypress.equals(kbShiftEnd)
+         ) {
              if (document.end()) {
                  alignCursor();
              }
-         } else if (keypress.equals(kbCtrlHome)) {
+         } else if (keypress.equals(kbCtrlHome)
+             || keypress.equals(kbCtrlShiftHome)
+         ) {
              document.setLineNumber(0);
              document.home();
              topLine = 0;
              leftColumn = 0;
              setCursorX(0);
              setCursorY(0);
-         } else if (keypress.equals(kbCtrlEnd)) {
+         } else if (keypress.equals(kbCtrlEnd)
+             || keypress.equals(kbCtrlShiftEnd)
+         ) {
              document.setLineNumber(document.getLineCount() - 1);
              document.end();
              alignTopLine(false);
          } else if (keypress.equals(kbIns)) {
-             document.setOverwrite(!document.getOverwrite());
+             document.setOverwrite(!document.isOverwrite());
          } else if (keypress.equals(kbDel)) {
-             document.del();
-             alignCursor();
+             if (inSelection) {
+                 deleteSelection();
+                 alignCursor();
+             } else {
+                 saveUndo();
+                 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(' ');
+             if (inSelection) {
+                 deleteSelection();
+                 alignTopLine(false);
+             } else {
+                 saveUndo();
+                 document.backspace();
+                 alignTopLine(false);
              }
+         } else if (keypress.equals(kbTab)) {
+             deleteSelection();
+             saveUndo();
+             document.tab();
+             alignCursor();
+         } else if (keypress.equals(kbShiftTab)) {
+             deleteSelection();
+             saveUndo();
+             document.backTab();
              alignCursor();
          } else if (keypress.equals(kbEnter)) {
+             deleteSelection();
+             saveUndo();
              document.enter();
              alignTopLine(true);
          } else if (!keypress.getKey().isFnKey()
              && !keypress.getKey().isCtrl()
          ) {
              // Plain old keystroke, process it
+             deleteSelection();
+             saveUndo();
              document.addChar(keypress.getKey().getChar());
              alignCursor();
          } else {
              // Pass other keys (tab etc.) on to TWidget
              super.onKeypress(keypress);
          }
+         if (inSelection) {
+             selectionColumn1 = document.getCursor();
+             selectionLine1 = document.getLineNumber();
+         }
      }
  
      /**
          }
      }
  
+     /**
+      * Handle posted command events.
+      *
+      * @param command command event
+      */
+     @Override
+     public void onCommand(final TCommandEvent command) {
+         if (command.equals(cmCut)) {
+             // Copy text to clipboard, and then remove it.
+             copySelection();
+             deleteSelection();
+             return;
+         }
+         if (command.equals(cmCopy)) {
+             // Copy text to clipboard.
+             copySelection();
+             return;
+         }
+         if (command.equals(cmPaste)) {
+             // Delete selected text, then paste text from clipboard.
+             deleteSelection();
+             String text = getClipboard().pasteText();
+             if (text != null) {
+                 for (int i = 0; i < text.length(); ) {
+                     int ch = text.codePointAt(i);
+                     switch (ch) {
+                     case '\n':
+                         onKeypress(new TKeypressEvent(kbEnter));
+                         break;
+                     case '\t':
+                         onKeypress(new TKeypressEvent(kbTab));
+                         break;
+                     default:
+                         if ((ch >= 0x20) && (ch != 0x7F)) {
+                             onKeypress(new TKeypressEvent(false, 0, ch,
+                                     false, false, false));
+                         }
+                         break;
+                     }
+                     i += Character.charCount(ch);
+                 }
+             }
+             return;
+         }
+         if (command.equals(cmClear)) {
+             // Remove text.
+             deleteSelection();
+             return;
+         }
+     }
+     // ------------------------------------------------------------------------
+     // TWidget ----------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Draw the text box.
+      */
+     @Override
+     public void draw() {
+         CellAttributes selectedColor = getTheme().getColor("teditor.selected");
+         boolean drawSelection = true;
+         int startCol = selectionColumn0;
+         int startRow = selectionLine0;
+         int endCol = selectionColumn1;
+         int endRow = selectionLine1;
+         if (((selectionColumn1 < selectionColumn0)
+                 && (selectionLine1 == selectionLine0))
+             || (selectionLine1 < selectionLine0)
+         ) {
+             // The user selected from bottom-to-top and/or right-to-left.
+             // Reverse the coordinates for the inverted section.
+             startCol = selectionColumn1;
+             startRow = selectionLine1;
+             endCol = selectionColumn0;
+             endRow = selectionLine0;
+         }
+         if ((startCol == endCol) && (startRow == endRow)) {
+             drawSelection = false;
+         }
+         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;
+                     }
+                 }
+                 // Highlight selected region
+                 if (inSelection && drawSelection) {
+                     if (startRow == endRow) {
+                         if (topLine + i == startRow) {
+                             for (x = startCol; x <= endCol; x++) {
+                                 putAttrXY(x - leftColumn, i, selectedColor);
+                             }
+                         }
+                     } else {
+                         if (topLine + i == startRow) {
+                             for (x = startCol; x < line.getDisplayLength(); x++) {
+                                 putAttrXY(x - leftColumn, i, selectedColor);
+                             }
+                         } else if (topLine + i == endRow) {
+                             for (x = 0; x <= endCol; x++) {
+                                 putAttrXY(x - leftColumn, i, selectedColor);
+                             }
+                         } else if ((topLine + i >= startRow)
+                             && (topLine + i <= endRow)
+                         ) {
+                             for (x = 0; x < getWidth(); x++) {
+                                 putAttrXY(x, i, selectedColor);
+                             }
+                         }
+                     }
+                 }
+             }
+         }
+     }
      // ------------------------------------------------------------------------
      // TEditorWidget ----------------------------------------------------------
      // ------------------------------------------------------------------------
  
+     /**
+      * Set the undo level.
+      *
+      * @param undoLevel the maximum number of undo operations
+      */
+     public void setUndoLevel(final int undoLevel) {
+         this.undoLevel = undoLevel;
+     }
      /**
       * Align visible area with document current line.
       *
          return document.getLineLengthMax() + 1;
      }
  
+     /**
+      * Get the current editing row plain text.  1-based.
+      *
+      * @param row the editing row number.  Row 1 is the first row.
+      * @return the plain text of the row
+      */
+     public String getEditingRawLine(final int row) {
+         Line line  = document.getLine(row - 1);
+         return line.getRawString();
+     }
      /**
       * Get the dirty value.
       *
          return document.isDirty();
      }
  
+     /**
+      * Unset the dirty flag.
+      */
+     public void setNotDirty() {
+         document.setNotDirty();
+     }
+     /**
+      * Get the overwrite value.
+      *
+      * @return true if new text will overwrite old text
+      */
+     public boolean isOverwrite() {
+         return document.isOverwrite();
+     }
      /**
       * Save contents to file.
       *
          document.saveToFilename(filename);
      }
  
+     /**
+      * Delete text within the selection bounds.
+      */
+     private void deleteSelection() {
+         if (!inSelection) {
+             return;
+         }
+         saveUndo();
+         inSelection = false;
+         int startCol = selectionColumn0;
+         int startRow = selectionLine0;
+         int endCol = selectionColumn1;
+         int endRow = selectionLine1;
+         /*
+         System.err.println("INITIAL: " + startRow + " " + startCol + " " +
+             endRow + " " + endCol + " " +
+             document.getLineNumber() + " " + document.getCursor());
+          */
+         if (((selectionColumn1 < selectionColumn0)
+                 && (selectionLine1 == selectionLine0))
+             || (selectionLine1 < selectionLine0)
+         ) {
+             // The user selected from bottom-to-top and/or right-to-left.
+             // Reverse the coordinates for the inverted section.
+             startCol = selectionColumn1;
+             startRow = selectionLine1;
+             endCol = selectionColumn0;
+             endRow = selectionLine0;
+             if (endRow >= document.getLineCount()) {
+                 // The selection started beyond EOF, trim it to EOF.
+                 endRow = document.getLineCount() - 1;
+                 endCol = document.getLine(endRow).getDisplayLength();
+             } else if (endRow == document.getLineCount() - 1) {
+                 // The selection started beyond EOF, trim it to EOF.
+                 if (endCol >= document.getLine(endRow).getDisplayLength()) {
+                     endCol = document.getLine(endRow).getDisplayLength() - 1;
+                 }
+             }
+         }
+         /*
+         System.err.println("FLIP: " + startRow + " " + startCol + " " +
+             endRow + " " + endCol + " " +
+             document.getLineNumber() + " " + document.getCursor());
+         System.err.println(" --END: " + endRow + " " + document.getLineCount() +
+             " " + document.getLine(endRow).getDisplayLength());
+          */
+         assert (endRow < document.getLineCount());
+         if (endCol >= document.getLine(endRow).getDisplayLength()) {
+             endCol = document.getLine(endRow).getDisplayLength() - 1;
+         }
+         if (endCol < 0) {
+             endCol = 0;
+         }
+         if (startCol >= document.getLine(startRow).getDisplayLength()) {
+             startCol = document.getLine(startRow).getDisplayLength() - 1;
+         }
+         if (startCol < 0) {
+             startCol = 0;
+         }
+         // Place the cursor on the selection end, and "press backspace" until
+         // the cursor matches the selection start.
+         /*
+         System.err.println("BEFORE: " + startRow + " " + startCol + " " +
+             endRow + " " + endCol + " " +
+             document.getLineNumber() + " " + document.getCursor());
+          */
+         document.setLineNumber(endRow);
+         document.setCursor(endCol + 1);
+         while (!((document.getLineNumber() == startRow)
+                 && (document.getCursor() == startCol))
+         ) {
+             /*
+             System.err.println("DURING: " + startRow + " " + startCol + " " +
+                 endRow + " " + endCol + " " +
+                 document.getLineNumber() + " " + document.getCursor());
+              */
+             document.backspace();
+         }
+         alignTopLine(true);
+     }
+     /**
+      * Copy text within the selection bounds to clipboard.
+      */
+     private void copySelection() {
+         if (!inSelection) {
+             return;
+         }
+         getClipboard().copyText(getSelection());
+     }
+     /**
+      * Set the selection.
+      *
+      * @param startRow the starting row number.  0-based: row 0 is the first
+      * row.
+      * @param startColumn the starting column number.  0-based: column 0 is
+      * the first column.
+      * @param endRow the ending row number.  0-based: row 0 is the first row.
+      * @param endColumn the ending column number.  0-based: column 0 is the
+      * first column.
+      */
+     public void setSelection(final int startRow, final int startColumn,
+         final int endRow, final int endColumn) {
+         inSelection = true;
+         selectionLine0 = startRow;
+         selectionColumn0 = startColumn;
+         selectionLine1 = endRow;
+         selectionColumn1 = endColumn;
+     }
+     /**
+      * Copy text within the selection bounds to a string.
+      *
+      * @return the selection as a string, or null if there is no selection
+      */
+     public String getSelection() {
+         if (!inSelection) {
+             return null;
+         }
+         int startCol = selectionColumn0;
+         int startRow = selectionLine0;
+         int endCol = selectionColumn1;
+         int endRow = selectionLine1;
+         if (((selectionColumn1 < selectionColumn0)
+                 && (selectionLine1 == selectionLine0))
+             || (selectionLine1 < selectionLine0)
+         ) {
+             // The user selected from bottom-to-top and/or right-to-left.
+             // Reverse the coordinates for the inverted section.
+             startCol = selectionColumn1;
+             startRow = selectionLine1;
+             endCol = selectionColumn0;
+             endRow = selectionLine0;
+         }
+         StringBuilder sb = new StringBuilder();
+         if (endRow > startRow) {
+             // First line
+             String line = document.getLine(startRow).getRawString();
+             int x = 0;
+             for (int i = 0; i < line.length(); ) {
+                 int ch = line.codePointAt(i);
+                 if (x >= startCol) {
+                     sb.append(Character.toChars(ch));
+                 }
+                 x += StringUtils.width(ch);
+                 i += Character.charCount(ch);
+             }
+             sb.append("\n");
+             // Middle lines
+             for (int y = startRow + 1; y < endRow; y++) {
+                 sb.append(document.getLine(y).getRawString());
+                 sb.append("\n");
+             }
+             // Final line
+             line = document.getLine(endRow).getRawString();
+             x = 0;
+             for (int i = 0; i < line.length(); ) {
+                 int ch = line.codePointAt(i);
+                 if (x > endCol) {
+                     break;
+                 }
+                 sb.append(Character.toChars(ch));
+                 x += StringUtils.width(ch);
+                 i += Character.charCount(ch);
+             }
+         } else {
+             assert (startRow == endRow);
+             // Only one line
+             String line = document.getLine(startRow).getRawString();
+             int x = 0;
+             for (int i = 0; i < line.length(); ) {
+                 int ch = line.codePointAt(i);
+                 if ((x >= startCol) && (x <= endCol)) {
+                     sb.append(Character.toChars(ch));
+                 }
+                 x += StringUtils.width(ch);
+                 i += Character.charCount(ch);
+             }
+         }
+         return sb.toString();
+     }
+     /**
+      * Get the selection starting row number.
+      *
+      * @return the starting row number, or -1 if there is no selection.
+      * 0-based: row 0 is the first row.
+      */
+     public int getSelectionStartRow() {
+         if (!inSelection) {
+             return -1;
+         }
+         int startCol = selectionColumn0;
+         int startRow = selectionLine0;
+         int endCol = selectionColumn1;
+         int endRow = selectionLine1;
+         if (((selectionColumn1 < selectionColumn0)
+                 && (selectionLine1 == selectionLine0))
+             || (selectionLine1 < selectionLine0)
+         ) {
+             // The user selected from bottom-to-top and/or right-to-left.
+             // Reverse the coordinates for the inverted section.
+             startCol = selectionColumn1;
+             startRow = selectionLine1;
+             endCol = selectionColumn0;
+             endRow = selectionLine0;
+         }
+         return startRow;
+     }
+     /**
+      * Get the selection starting column number.
+      *
+      * @return the starting column number, or -1 if there is no selection.
+      * 0-based: column 0 is the first column.
+      */
+     public int getSelectionStartColumn() {
+         if (!inSelection) {
+             return -1;
+         }
+         int startCol = selectionColumn0;
+         int startRow = selectionLine0;
+         int endCol = selectionColumn1;
+         int endRow = selectionLine1;
+         if (((selectionColumn1 < selectionColumn0)
+                 && (selectionLine1 == selectionLine0))
+             || (selectionLine1 < selectionLine0)
+         ) {
+             // The user selected from bottom-to-top and/or right-to-left.
+             // Reverse the coordinates for the inverted section.
+             startCol = selectionColumn1;
+             startRow = selectionLine1;
+             endCol = selectionColumn0;
+             endRow = selectionLine0;
+         }
+         return startCol;
+     }
+     /**
+      * Get the selection ending row number.
+      *
+      * @return the ending row number, or -1 if there is no selection.
+      * 0-based: row 0 is the first row.
+      */
+     public int getSelectionEndRow() {
+         if (!inSelection) {
+             return -1;
+         }
+         int startCol = selectionColumn0;
+         int startRow = selectionLine0;
+         int endCol = selectionColumn1;
+         int endRow = selectionLine1;
+         if (((selectionColumn1 < selectionColumn0)
+                 && (selectionLine1 == selectionLine0))
+             || (selectionLine1 < selectionLine0)
+         ) {
+             // The user selected from bottom-to-top and/or right-to-left.
+             // Reverse the coordinates for the inverted section.
+             startCol = selectionColumn1;
+             startRow = selectionLine1;
+             endCol = selectionColumn0;
+             endRow = selectionLine0;
+         }
+         return endRow;
+     }
+     /**
+      * Get the selection ending column number.
+      *
+      * @return the ending column number, or -1 if there is no selection.
+      * 0-based: column 0 is the first column.
+      */
+     public int getSelectionEndColumn() {
+         if (!inSelection) {
+             return -1;
+         }
+         int startCol = selectionColumn0;
+         int startRow = selectionLine0;
+         int endCol = selectionColumn1;
+         int endRow = selectionLine1;
+         if (((selectionColumn1 < selectionColumn0)
+                 && (selectionLine1 == selectionLine0))
+             || (selectionLine1 < selectionLine0)
+         ) {
+             // The user selected from bottom-to-top and/or right-to-left.
+             // Reverse the coordinates for the inverted section.
+             startCol = selectionColumn1;
+             startRow = selectionLine1;
+             endCol = selectionColumn0;
+             endRow = selectionLine0;
+         }
+         return endCol;
+     }
+     /**
+      * Unset the selection.
+      */
+     public void unsetSelection() {
+         inSelection = false;
+     }
+     /**
+      * Replace whatever is being selected with new text.  If not in
+      * selection, nothing is replaced.
+      *
+      * @param text the new replacement text
+      */
+     public void replaceSelection(final String text) {
+         if (!inSelection) {
+             return;
+         }
+         // Delete selected text, then paste text from clipboard.
+         deleteSelection();
+         for (int i = 0; i < text.length(); ) {
+             int ch = text.codePointAt(i);
+             switch (ch) {
+             case '\n':
+                 onKeypress(new TKeypressEvent(kbEnter));
+                 break;
+             case '\t':
+                 onKeypress(new TKeypressEvent(kbTab));
+                 break;
+             default:
+                 if ((ch >= 0x20) && (ch != 0x7F)) {
+                     onKeypress(new TKeypressEvent(false, 0, ch,
+                             false, false, false));
+                 }
+                 break;
+             }
+             i += Character.charCount(ch);
+         }
+     }
+     /**
+      * Check if selection is available.
+      *
+      * @return true if a selection has been made
+      */
+     public boolean hasSelection() {
+         return inSelection;
+     }
+     /**
+      * Get the entire contents of the editor as one string.
+      *
+      * @return the editor contents
+      */
+     public String getText() {
+         return document.getText();
+     }
+     /**
+      * Set the entire contents of the editor from one string.
+      *
+      * @param text the new contents
+      */
+     public void setText(final String text) {
+         document = new Document(text, defaultColor);
+         unsetSelection();
+         topLine = 0;
+         leftColumn = 0;
+     }
+     // ------------------------------------------------------------------------
+     // EditMenuUser -----------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Check if the cut menu item should be enabled.
+      *
+      * @return true if the cut menu item should be enabled
+      */
+     public boolean isEditMenuCut() {
+         return true;
+     }
+     /**
+      * Check if the copy menu item should be enabled.
+      *
+      * @return true if the copy menu item should be enabled
+      */
+     public boolean isEditMenuCopy() {
+         return true;
+     }
+     /**
+      * Check if the paste menu item should be enabled.
+      *
+      * @return true if the paste menu item should be enabled
+      */
+     public boolean isEditMenuPaste() {
+         return true;
+     }
+     /**
+      * Check if the clear menu item should be enabled.
+      *
+      * @return true if the clear menu item should be enabled
+      */
+     public boolean isEditMenuClear() {
+         return true;
+     }
+     /**
+      * Save undo state.
+      */
+     private void saveUndo() {
+         SavedState state = new SavedState();
+         state.document = document.dup();
+         state.topLine = topLine;
+         state.leftColumn = leftColumn;
+         if (undoLevel > 0) {
+             while (undoList.size() > undoLevel) {
+                 undoList.remove(0);
+             }
+         }
+         undoList.add(state);
+         undoListI = undoList.size() - 1;
+     }
+     /**
+      * Undo an edit.
+      */
+     public void undo() {
+         inSelection = false;
+         if ((undoListI >= 0) && (undoListI < undoList.size())) {
+             SavedState state = undoList.get(undoListI);
+             document = state.document.dup();
+             topLine = state.topLine;
+             leftColumn = state.leftColumn;
+             undoListI--;
+             setCursorY(document.getLineNumber() - topLine);
+             alignCursor();
+         }
+     }
+     /**
+      * Redo an edit.
+      */
+     public void redo() {
+         inSelection = false;
+         if ((undoListI >= 0) && (undoListI < undoList.size())) {
+             SavedState state = undoList.get(undoListI);
+             document = state.document.dup();
+             topLine = state.topLine;
+             leftColumn = state.leftColumn;
+             undoListI++;
+             setCursorY(document.getLineNumber() - topLine);
+             alignCursor();
+         }
+     }
+     /**
+      * Trim trailing whitespace from lines and trailing empty
+      * lines from the document.
+      */
+     public void cleanWhitespace() {
+         document.cleanWhitespace();
+         setCursorY(document.getLineNumber() - topLine);
+         alignCursor();
+     }
+     /**
+      * Set keyword highlighting.
+      *
+      * @param enabled if true, enable keyword highlighting
+      */
+     public void setHighlighting(final boolean enabled) {
+         document.setHighlighting(enabled);
+     }
  }
diff --combined TEditorWindow.java
index d78185c32f3096cd615f345d9731751d285fc3b6,a28376ba3a18e8fcdfd75a300a32c180c2946385..a28376ba3a18e8fcdfd75a300a32c180c2946385
@@@ -44,8 -44,10 +44,10 @@@ import jexer.bits.CellAttributes
  import jexer.bits.GraphicsChars;
  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.*;
  
@@@ -150,28 -152,27 +152,27 @@@ public class TEditorWindow extends TScr
      }
  
      // ------------------------------------------------------------------------
-     // TWindow ----------------------------------------------------------------
+     // Event handlers ---------------------------------------------------------
      // ------------------------------------------------------------------------
  
      /**
-      * Draw the window.
+      * Called by application.switchWindow() when this window gets the
+      * focus, and also by application.addWindow().
       */
-     @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);
+     public void onFocus() {
+         super.onFocus();
+         getApplication().enableMenuItem(TMenu.MID_UNDO);
+         getApplication().enableMenuItem(TMenu.MID_REDO);
+     }
  
-         if (editField.isDirty()) {
-             putCharXY(2, getHeight() - 1, GraphicsChars.OCTOSTAR, borderColor);
-         }
+     /**
+      * Called by application.switchWindow() when another window gets the
+      * focus.
+      */
+     public void onUnfocus() {
+         super.onUnfocus();
+         getApplication().disableMenuItem(TMenu.MID_UNDO);
+         getApplication().disableMenuItem(TMenu.MID_REDO);
      }
  
      /**
          super.onCommand(command);
      }
  
+     /**
+      * Handle posted menu events.
+      *
+      * @param menu menu event
+      */
+     @Override
+     public void onMenu(final TMenuEvent menu) {
+         switch (menu.getId()) {
+         case TMenu.MID_UNDO:
+             editField.undo();
+             break;
+         case TMenu.MID_REDO:
+             editField.redo();
+             break;
+         }
+     }
+     // ------------------------------------------------------------------------
+     // 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);
+         }
+     }
      /**
       * Returns true if this window does not want the application-wide mouse
       * cursor drawn over it.
diff --combined TExceptionDialog.java
index 227aceb5764b92d26a0cac983030a0def359a402,f526a6470a2db15634fdeaeb1d09d22c8ca16589..f526a6470a2db15634fdeaeb1d09d22c8ca16589
@@@ -82,7 -82,7 +82,7 @@@ public class TExceptionDialog extends T
          final Throwable exception) {
  
          super(application, i18n.getString("windowTitle"),
-             1, 1, 70, 20, CENTERED | MODAL);
+             1, 1, 78, 22, CENTERED | MODAL);
  
          this.exception = exception;
  
              2, 6, "ttext", false);
  
          ArrayList<String> stackTraceStrings = new ArrayList<String>();
+         stackTraceStrings.add(exception.getMessage());
          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);
+         stackTrace = addList(stackTraceStrings, 2, 7, getWidth() - 6, 10);
  
          // Buttons
-         addButton(i18n.getString("saveButton"), 19, getHeight() - 4,
+         addButton(i18n.getString("saveButton"), 21, getHeight() - 4,
              new TAction() {
                  public void DO() {
                      saveToFile();
              });
  
          TButton closeButton = addButton(i18n.getString("closeButton"),
-             35, getHeight() - 4,
+             37, getHeight() - 4,
              new TAction() {
                  public void DO() {
                      // Don't do anything, just close the window.
index d07998cf2931c6956becc0d2a177f90d5a153d8c,9e5857a6deff14c2374b09c41ce5d1fc2362469d..9e5857a6deff14c2374b09c41ce5d1fc2362469d
@@@ -1,10 -1,10 +1,10 @@@
  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.
+ captionLine1=An error has occurred.  This may be due to a programming bug, but could
+ captionLine2=also be a correctable or temporary issue.  The stack trace is reported
+ captionLine3=below.  If you wish to submit a bug report, please use the Save button
+ captionLine4=to create a more detailed error log.
  
  exceptionString={0}: {1}
  
diff --combined TField.java
index 7c8b5bc415e62882a24941734da6ac213c706b75,90dd4e427abcf5660bfcc04b32b76c7c5da8edf7..90dd4e427abcf5660bfcc04b32b76c7c5da8edf7
@@@ -31,14 -31,16 +31,16 @@@ package jexer
  import jexer.bits.CellAttributes;
  import jexer.bits.GraphicsChars;
  import jexer.bits.StringUtils;
+ import jexer.event.TCommandEvent;
  import jexer.event.TKeypressEvent;
  import jexer.event.TMouseEvent;
+ import static jexer.TCommand.*;
  import static jexer.TKeypress.*;
  
  /**
   * TField implements an editable text field.
   */
- public class TField extends TWidget {
+ public class TField extends TWidget implements EditMenuUser {
  
      // ------------------------------------------------------------------------
      // Variables --------------------------------------------------------------
  
          if (keypress.equals(kbRight)) {
              if (position < text.length()) {
+                 int lastPosition = position;
                  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));
+                         position -= Character.charCount(text.codePointAt(lastPosition));
                      }
                  } else {
                      while ((screenPosition - windowStart +
          super.onKeypress(keypress);
      }
  
+     /**
+      * Handle posted command events.
+      *
+      * @param command command event
+      */
+     @Override
+     public void onCommand(final TCommandEvent command) {
+         if (command.equals(cmCut)) {
+             // Copy text to clipboard, and then remove it.
+             getClipboard().copyText(text);
+             setText("");
+             return;
+         }
+         if (command.equals(cmCopy)) {
+             // Copy text to clipboard.
+             getClipboard().copyText(text);
+             return;
+         }
+         if (command.equals(cmPaste)) {
+             // Paste text from clipboard.
+             String newText = getClipboard().pasteText();
+             if (newText != null) {
+                 setText(newText);
+             }
+             return;
+         }
+         if (command.equals(cmClear)) {
+             // Remove text.
+             setText("");
+             return;
+         }
+     }
      // ------------------------------------------------------------------------
      // TWidget ----------------------------------------------------------------
      // ------------------------------------------------------------------------
          assert (text != null);
          this.text = text;
          position = 0;
+         screenPosition = 0;
          windowStart = 0;
+         if ((fixed == true) && (this.text.length() > getWidth())) {
+             this.text = this.text.substring(0, getWidth());
+         }
      }
  
      /**
          updateAction = action;
      }
  
+     // ------------------------------------------------------------------------
+     // EditMenuUser -----------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Check if the cut menu item should be enabled.
+      *
+      * @return true if the cut menu item should be enabled
+      */
+     public boolean isEditMenuCut() {
+         return true;
+     }
+     /**
+      * Check if the copy menu item should be enabled.
+      *
+      * @return true if the copy menu item should be enabled
+      */
+     public boolean isEditMenuCopy() {
+         return true;
+     }
+     /**
+      * Check if the paste menu item should be enabled.
+      *
+      * @return true if the paste menu item should be enabled
+      */
+     public boolean isEditMenuPaste() {
+         return true;
+     }
+     /**
+      * Check if the clear menu item should be enabled.
+      *
+      * @return true if the clear menu item should be enabled
+      */
+     public boolean isEditMenuClear() {
+         return true;
+     }
  }
diff --combined TImage.java
index cd0ce96e0baf4523c64cb45527c01cbc4d1e1443,b7bfbd00a2ff165becc701575023b42e50b6deed..b7bfbd00a2ff165becc701575023b42e50b6deed
@@@ -30,19 -30,18 +30,18 @@@ 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.TCommandEvent;
  import jexer.event.TKeypressEvent;
  import jexer.event.TMouseEvent;
  import jexer.event.TResizeEvent;
+ import static jexer.TCommand.*;
  import static jexer.TKeypress.*;
  
  /**
   * TImage renders a piece of a bitmap image on screen.
   */
- public class TImage extends TWidget {
+ public class TImage extends TWidget implements EditMenuUser {
  
      // ------------------------------------------------------------------------
      // Constants --------------------------------------------------------------
          resized = true;
      }
  
+     /**
+      * Handle posted command events.
+      *
+      * @param command command event
+      */
+     @Override
+     public void onCommand(final TCommandEvent command) {
+         if (command.equals(cmCopy)) {
+             // Copy image to clipboard.
+             getClipboard().copyImage(image);
+             return;
+         }
+     }
      // ------------------------------------------------------------------------
      // TWidget ----------------------------------------------------------------
      // ------------------------------------------------------------------------
                      }
  
                      Cell cell = new Cell();
-                     cell.setImage(image.getSubimage(x * textWidth,
-                             y * textHeight, width, height));
+                     if ((width != textWidth) || (height != textHeight)) {
+                         BufferedImage newImage;
+                         newImage = new BufferedImage(textWidth, textHeight,
+                             BufferedImage.TYPE_INT_ARGB);
+                         java.awt.Graphics gr = newImage.getGraphics();
+                         gr.drawImage(image.getSubimage(x * textWidth,
+                                 y * textHeight, width, height),
+                             0, 0, null, null);
+                         gr.dispose();
+                         cell.setImage(newImage);
+                     } else {
+                         cell.setImage(image.getSubimage(x * textWidth,
+                                 y * textHeight, width, height));
+                     }
  
                      cells[x][y] = cell;
                  }
          return newImage;
      }
  
+     // ------------------------------------------------------------------------
+     // EditMenuUser -----------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Check if the cut menu item should be enabled.
+      *
+      * @return true if the cut menu item should be enabled
+      */
+     public boolean isEditMenuCut() {
+         return false;
+     }
+     /**
+      * Check if the copy menu item should be enabled.
+      *
+      * @return true if the copy menu item should be enabled
+      */
+     public boolean isEditMenuCopy() {
+         return true;
+     }
+     /**
+      * Check if the paste menu item should be enabled.
+      *
+      * @return true if the paste menu item should be enabled
+      */
+     public boolean isEditMenuPaste() {
+         return false;
+     }
+     /**
+      * Check if the clear menu item should be enabled.
+      *
+      * @return true if the clear menu item should be enabled
+      */
+     public boolean isEditMenuClear() {
+         return false;
+     }
  }
diff --combined TKeypress.java
index c965e7dbab48873ae31a35963d4aaef4231cdcaf,20db8bb267ebc43389f2d900c49aa5b3462d22d1..20db8bb267ebc43389f2d900c49aa5b3462d22d1
@@@ -612,6 -612,41 +612,41 @@@ public class TKeypress 
      public static final TKeypress kbAltShiftZ = new TKeypress(false,
              0, 'Z', true, false, true);
  
+     public static final TKeypress kbAltShiftHome = new TKeypress(true,
+             TKeypress.HOME, ' ', true, false, true);
+     public static final TKeypress kbAltShiftEnd = new TKeypress(true,
+             TKeypress.END, ' ', true, false, true);
+     public static final TKeypress kbAltShiftPgUp = new TKeypress(true,
+             TKeypress.PGUP, ' ', true, false, true);
+     public static final TKeypress kbAltShiftPgDn = new TKeypress(true,
+             TKeypress.PGDN, ' ', true, false, true);
+     public static final TKeypress kbAltShiftUp = new TKeypress(true,
+             TKeypress.UP, ' ', true, false, true);
+     public static final TKeypress kbAltShiftDown = new TKeypress(true,
+             TKeypress.DOWN, ' ', true, false, true);
+     public static final TKeypress kbAltShiftLeft = new TKeypress(true,
+             TKeypress.LEFT, ' ', true, false, true);
+     public static final TKeypress kbAltShiftRight = new TKeypress(true,
+             TKeypress.RIGHT, ' ', true, false, true);
+     public static final TKeypress kbCtrlShiftHome = new TKeypress(true,
+             TKeypress.HOME, ' ', false, true, true);
+     public static final TKeypress kbCtrlShiftEnd = new TKeypress(true,
+             TKeypress.END, ' ', false, true, true);
+     public static final TKeypress kbCtrlShiftPgUp = new TKeypress(true,
+             TKeypress.PGUP, ' ', false, true, true);
+     public static final TKeypress kbCtrlShiftPgDn = new TKeypress(true,
+             TKeypress.PGDN, ' ', false, true, true);
+     public static final TKeypress kbCtrlShiftUp = new TKeypress(true,
+             TKeypress.UP, ' ', false, true, true);
+     public static final TKeypress kbCtrlShiftDown = new TKeypress(true,
+             TKeypress.DOWN, ' ', false, true, true);
+     public static final TKeypress kbCtrlShiftLeft = new TKeypress(true,
+             TKeypress.LEFT, ' ', false, true, true);
+     public static final TKeypress kbCtrlShiftRight = new TKeypress(true,
+             TKeypress.RIGHT, ' ', false, true, true);
      /**
       * Backspace as ^H.
       */
              return "\u25C0\u2500\u2518";
          }
  
+         // Special case: Space is "Space"
+         if (equals(kbSpace)) {
+             return "Space";
+         }
          if (equals(kbShiftLeft)) {
              return "Shift+\u2190";
          }
diff --combined TList.java
index 38a994c8215bbba2a53e3c85d0b15bd46c099146,12e0b8a33cce977e93ce4a6fd30080adeb75e8dc..12e0b8a33cce977e93ce4a6fd30080adeb75e8dc
@@@ -335,21 -335,28 +335,28 @@@ public class TList extends TScrollableW
      @Override
      public void setWidth(final int width) {
          super.setWidth(width);
-         hScroller.setWidth(getWidth() - 1);
-         vScroller.setX(getWidth() - 1);
+         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);
-         hScroller.setY(getHeight() - 1);
-         vScroller.setHeight(getHeight() - 1);
+         if (hScroller != null) {
+             hScroller.setY(getHeight() - 1);
+         }
+         if (vScroller != null) {
+             vScroller.setHeight(getHeight() - 1);
+         }
      }
  
      /**
          int topY = 0;
          for (int i = begin; i < strings.size(); i++) {
              String line = strings.get(i);
+             if (line == null) {
+                 line = "";
+             }
              if (getHorizontalValue() < line.length()) {
                  line = line.substring(getHorizontalValue());
              } else {
diff --combined TPasswordField.java
index 9c200d7dc7dcfea44a2fef0d3f59f8df9505ec81,0be2b98ce3ff73e0a9668b621f2639df791f0c1d..0be2b98ce3ff73e0a9668b621f2639df791f0c1d
@@@ -29,7 -29,6 +29,6 @@@
  package jexer;
  
  import jexer.bits.CellAttributes;
- import jexer.bits.GraphicsChars;
  import jexer.bits.StringUtils;
  
  /**
diff --combined TRadioButton.java
index 60a628845ca2c5c920863bd31be7597c95709266,dcc5c13699f03fefceabbc54bf65bac1b6624745..dcc5c13699f03fefceabbc54bf65bac1b6624745
@@@ -50,9 -50,9 +50,9 @@@ public class TRadioButton extends TWidg
      // ------------------------------------------------------------------------
  
      /**
-      * RadioButton state, true means selected.
+      * RadioButton state, true means selected.  Note package private access.
       */
-     private boolean selected = false;
+     boolean selected = false;
  
      /**
       * The shortcut and radio button label.
  
      /**
       * ID for this radio button.  Buttons start counting at 1 in the
-      * RadioGroup.
+      * RadioGroup.  Note package private access.
       */
-     private int id;
+     int id;
  
      // ------------------------------------------------------------------------
      // Constructors -----------------------------------------------------------
      // ------------------------------------------------------------------------
  
      /**
-      * Public constructor.
+      * Package private constructor.
       *
       * @param parent parent widget
       * @param x column relative to parent
@@@ -78,7 -78,7 +78,7 @@@
       * @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,
+     TRadioButton(final TRadioGroup parent, final int x, final int y,
          final String label, final int id) {
  
          // Set parent and window
@@@ -89,6 -89,8 +89,8 @@@
  
          setCursorVisible(true);
          setCursorX(1);
+         parent.addRadioButton(this);
      }
  
      // ------------------------------------------------------------------------
      public void onMouseDown(final TMouseEvent mouse) {
          if ((mouseOnRadioButton(mouse)) && (mouse.isMouse1())) {
              // Switch state
-             selected = true;
-             ((TRadioGroup) getParent()).setSelected(this);
+             ((TRadioGroup) getParent()).setSelected(id);
          }
      }
  
      public void onKeypress(final TKeypressEvent keypress) {
  
          if (keypress.equals(kbSpace)) {
-             selected = true;
-             ((TRadioGroup) getParent()).setSelected(this);
+             ((TRadioGroup) getParent()).setSelected(id);
              return;
          }
  
      }
  
      /**
-      * Set RadioButton state, true means selected.  Note package private
-      * access.
+      * Set RadioButton state, true means selected.
       *
       * @param selected if true then this is the one button in the group that
       * is selected
       */
-     void setSelected(final boolean selected) {
-         this.selected = selected;
+     public void setSelected(final boolean selected) {
+         if (selected == true) {
+             ((TRadioGroup) getParent()).setSelected(id);
+         } else {
+             ((TRadioGroup) getParent()).setSelected(0);
+         }
      }
  
      /**
diff --combined TRadioGroup.java
index a82b074f8ce9a1c4fe6de462433b8d4124507b91,d6bd7ff38ee34c2b71a3546fb1cf1347e29c1dfe..d6bd7ff38ee34c2b71a3546fb1cf1347e29c1dfe
@@@ -54,12 -54,30 +54,30 @@@ public class TRadioGroup extends TWidge
       * If true, one of the children MUST be selected.  Note package private
       * access.
       */
-     boolean requiresSelection = true;
+     boolean requiresSelection = false;
  
      // ------------------------------------------------------------------------
      // Constructors -----------------------------------------------------------
      // ------------------------------------------------------------------------
  
+     /**
+      * Public constructor.
+      *
+      * @param parent parent widget
+      * @param x column relative to parent
+      * @param y row relative to parent
+      * @param width width of group
+      * @param label label to display on the group box
+      */
+     public TRadioGroup(final TWidget parent, final int x, final int y,
+         final int width, final String label) {
+         // Set parent and window
+         super(parent, x, y, width, 2);
+         this.label = label;
+     }
      /**
       * Public constructor.
       *
          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.
       *
              return;
          }
  
+         for (TWidget widget: getChildren()) {
+             ((TRadioButton) widget).selected = false;
+         }
          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);
+         button.selected = true;
          selectedButton = button;
      }
  
+     /**
+      * Get the radio button that was selected.
+      *
+      * @return the selected button, or null if no button is selected
+      */
+     public TRadioButton getSelectedButton() {
+         return selectedButton;
+     }
+     /**
+      * Convenience function to add a radio button to this group.
+      *
+      * @param label label to display next to (right of) the radiobutton
+      * @param selected if true, this will be the selected radiobutton
+      * @return the new radio button
+      */
+     public TRadioButton addRadioButton(final String label,
+         final boolean selected) {
+         TRadioButton button = addRadioButton(label);
+         setSelected(button.id);
+         return button;
+     }
      /**
       * Convenience function to add a radio button to this group.
       *
       * @return the new radio button
       */
      public TRadioButton addRadioButton(final String label) {
-         int buttonX = 1;
-         int buttonY = getChildren().size() + 1;
+         return new TRadioButton(this, 0, 0, label, 0);
+     }
+     /**
+      * Package private method for RadioButton to add itself to a RadioGroup
+      * container.
+      *
+      * @param button the button to add
+      */
+     void addRadioButton(final TRadioButton button) {
+         super.setHeight(getChildren().size() + 2);
+         button.setX(1);
+         button.setY(getChildren().size());
+         button.id = getChildren().size();
+         String label = button.getMnemonic().getRawLabel();
          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;
+     /**
+      * Get the requires selection flag.
+      *
+      * @return true if this radiogroup requires that one of the buttons be
+      * selected
+      */
+     public boolean getRequiresSelection() {
+         return requiresSelection;
+     }
+     /**
+      * Set the requires selection flag.
+      *
+      * @param requiresSelection if true, then this radiogroup requires that
+      * one of the buttons be selected
+      */
+     public void setRequiresSelection(final boolean requiresSelection) {
+         this.requiresSelection = requiresSelection;
+         if (requiresSelection) {
+             if ((getChildren().size() > 0) && (selectedButton == null)) {
+                 setSelected(1);
+             }
+         }
      }
  
  }
diff --combined TSplitPane.java
index 7c85278f88d0d6df3ff34f01064e2be57d25481d,b308e9b79162a57aea99d55bad2a77f22e951eb3..b308e9b79162a57aea99d55bad2a77f22e951eb3
@@@ -30,10 -30,8 +30,8 @@@ 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
@@@ -225,7 -223,28 +223,28 @@@ public class TSplitPane extends TWidge
          CellAttributes attr = getTheme().getColor("tsplitpane");
          if (vertical) {
              vLineXY(split, 0, getHeight(), GraphicsChars.WINDOW_SIDE, attr);
-             // TODO: draw intersections of children
+             // Draw intersections of children
+             if ((left instanceof TSplitPane)
+                 && (((TSplitPane) left).vertical == false)
+                 && (right instanceof TSplitPane)
+                 && (((TSplitPane) right).vertical == false)
+                 && (((TSplitPane) left).split == ((TSplitPane) right).split)
+             ) {
+                 putCharXY(split, ((TSplitPane) left).split, '\u253C', attr);
+             } else {
+                 if ((left instanceof TSplitPane)
+                     && (((TSplitPane) left).vertical == false)
+                 ) {
+                     putCharXY(split, ((TSplitPane) left).split, '\u2524', attr);
+                 }
+                 if ((right instanceof TSplitPane)
+                     && (((TSplitPane) right).vertical == false)
+                 ) {
+                     putCharXY(split, ((TSplitPane) right).split, '\u251C',
+                         attr);
+                 }
+             }
  
              if ((mouse != null)
                  && (mouse.getAbsoluteX() == getAbsoluteX() + split)
              }
          } else {
              hLineXY(0, split, getWidth(), GraphicsChars.SINGLE_BAR, attr);
-             // TODO: draw intersections of children
+             // Draw intersections of children
+             if ((top instanceof TSplitPane)
+                 && (((TSplitPane) top).vertical == true)
+                 && (bottom instanceof TSplitPane)
+                 && (((TSplitPane) bottom).vertical == true)
+                 && (((TSplitPane) top).split == ((TSplitPane) bottom).split)
+             ) {
+                 putCharXY(((TSplitPane) top).split, split, '\u253C', attr);
+             } else {
+                 if ((top instanceof TSplitPane)
+                     && (((TSplitPane) top).vertical == true)
+                 ) {
+                     putCharXY(((TSplitPane) top).split, split, '\u2534', attr);
+                 }
+                 if ((bottom instanceof TSplitPane)
+                     && (((TSplitPane) bottom).vertical == true)
+                 ) {
+                     putCharXY(((TSplitPane) bottom).split, split, '\u252C',
+                         attr);
+                 }
+             }
  
              if ((mouse != null)
                  && (mouse.getAbsoluteY() == getAbsoluteY() + split)
              keep.onResize(new TResizeEvent(TResizeEvent.Type.WIDGET, getWidth(),
                      getHeight()));
          }
-         
          return keep;
      }
  
diff --combined TTableWidget.java
index 9b4d7c9847faaa6688dae4a65b63173169683e77,749b7313c9e29874cacacc431305ebf81e6d70ce..749b7313c9e29874cacacc431305ebf81e6d70ce
@@@ -1426,8 -1426,6 +1426,6 @@@ public class TTableWidget extends TWidg
                  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) {
diff --combined TTableWindow.java
index 44ff7b48949749c1590bcead79b57f3b55d727fc,766ceafdd901a0a4044105aba5114093cf7ef7a6..766ceafdd901a0a4044105aba5114093cf7ef7a6
@@@ -112,7 -112,6 +112,6 @@@ public class TTableWindow extends TScro
       */
      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);
       */
      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);
diff --combined TTerminalWidget.java
index a2696092ce82ee7ed0550a0019b6b8fc0c9c785d,bf51e6b5c2fd67b78b55d6e280e44f68d3b335aa..bf51e6b5c2fd67b78b55d6e280e44f68d3b335aa
   */
  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.File;
  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.TCommandEvent;
  import jexer.event.TKeypressEvent;
  import jexer.event.TMenuEvent;
  import jexer.event.TMouseEvent;
@@@ -57,13 -51,14 +51,14 @@@ import jexer.menu.TMenu
  import jexer.tterminal.DisplayLine;
  import jexer.tterminal.DisplayListener;
  import jexer.tterminal.ECMA48;
+ import static jexer.TCommand.*;
  import static jexer.TKeypress.*;
  
  /**
   * TTerminalWidget exposes a ECMA-48 / ANSI X3.64 style terminal in a widget.
   */
  public class TTerminalWidget extends TScrollableWidget
-                              implements DisplayListener {
+                              implements DisplayListener, EditMenuUser {
  
      /**
       * Translated strings.
       */
      private Process shell;
  
+     /**
+      * If true, something called 'ptypipe' is on the PATH and executable.
+      */
+     private static boolean ptypipeOnPath = false;
      /**
       * If true, we are using the ptypipe utility to support dynamic window
       * resizing.  ptypipe is available at
      // Constructors -----------------------------------------------------------
      // ------------------------------------------------------------------------
  
+     /**
+      * Static constructor.
+      */
+     static {
+         checkForPtypipe();
+     }
      /**
       * Public constructor spawns a custom command line.
       *
       * @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
+      * @param closeAction action to perform when the shell exits
       */
      public TTerminalWidget(final TWidget parent, final int x, final int y,
          final String [] command, final TAction closeAction) {
       * @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
+      * @param closeAction action to perform when the shell exits
       */
      public TTerminalWidget(final TWidget parent, final int x, final int y,
          final int width, final int height, final String [] command,
              fullCommand = new String[command.length + 1];
              fullCommand[0] = "ptypipe";
              System.arraycopy(command, 0, fullCommand, 1, command.length);
+         } else if (System.getProperty("jexer.TTerminal.ptypipe",
+                 "auto").equals("auto")
+             && (ptypipeOnPath == 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[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);
+             if (System.getProperty("jexer.TTerminal.setsid",
+                     "true").equals("false")
+             ) {
+                 fullCommand = new String[5];
+                 fullCommand[0] = "script";
+                 fullCommand[1] = "-fqe";
+                 fullCommand[2] = "/dev/null";
+                 fullCommand[3] = "-c";
+                 fullCommand[4] = stringArrayToString(command);
+             } else {
+                 fullCommand = new String[6];
+                 fullCommand[0] = "setsid";
+                 fullCommand[1] = "script";
+                 fullCommand[2] = "-fqe";
+                 fullCommand[3] = "/dev/null";
+                 fullCommand[4] = "-c";
+                 fullCommand[5] = stringArrayToString(command);
+             }
          }
          spawnShell(fullCommand);
      }
       * @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
+      * @param closeAction action to perform when the shell exits
       */
      public TTerminalWidget(final TWidget parent, final int x, final int y,
          final TAction closeAction) {
       * @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
+      * @param closeAction action to perform when the shell exits
       */
      public TTerminalWidget(final TWidget parent, final int x, final int y,
          final int width, final int height, final TAction closeAction) {
          // GNU differ on the '-f' vs '-F' flags, we need two different
          // commands.  Lovely.
          String cmdShellGNU = "script -fqe /dev/null";
+         String cmdShellGNUSetsid = "setsid script -fqe /dev/null";
          String cmdShellBSD = "script -q -F /dev/null";
  
          // ptypipe is another solution that permits dynamic window resizing.
          ) {
              ptypipe = true;
              spawnShell(cmdShellPtypipe.split("\\s+"));
+         } else if (System.getProperty("jexer.TTerminal.ptypipe",
+                 "auto").equals("auto")
+             && (ptypipeOnPath == 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+"));
+             if (System.getProperty("jexer.TTerminal.setsid",
+                     "true").equals("false")
+             ) {
+                 spawnShell(cmdShellGNU.split("\\s+"));
+             } else {
+                 spawnShell(cmdShellGNUSetsid.split("\\s+"));
+             }
          } else {
              // When all else fails, assume GNU.
              spawnShell(cmdShellGNU.split("\\s+"));
          super.onMouseMotion(mouse);
      }
  
+     /**
+      * Handle posted command events.
+      *
+      * @param command command event
+      */
+     @Override
+     public void onCommand(final TCommandEvent command) {
+         if (emulator == null) {
+             return;
+         }
+         if (command.equals(cmPaste)) {
+             // Paste text from clipboard.
+             String text = getClipboard().pasteText();
+             if (text != null) {
+                 for (int i = 0; i < text.length(); ) {
+                     int ch = text.codePointAt(i);
+                     emulator.addUserEvent(new TKeypressEvent(false, 0, ch,
+                             false, false, false));
+                     i += Character.charCount(ch);
+                 }
+             }
+             return;
+         }
+     }
      // ------------------------------------------------------------------------
      // TScrollableWidget ------------------------------------------------------
      // ------------------------------------------------------------------------
          int width = getDisplayWidth();
  
          boolean syncEmulator = false;
-         if ((System.currentTimeMillis() - lastUpdateTime >= 20)
-             && (dirty == true)
-         ) {
+         if (System.currentTimeMillis() - lastUpdateTime >= 50) {
              // Too much time has passed, draw it all.
              syncEmulator = true;
          } else if (emulator.isReading() && (dirty == false)) {
      // TTerminalWidget --------------------------------------------------------
      // ------------------------------------------------------------------------
  
+     /**
+      * Check for 'ptypipe' on the path.  If available, set ptypipeOnPath.
+      */
+     private static void checkForPtypipe() {
+         String systemPath = System.getenv("PATH");
+         if (systemPath == null) {
+             return;
+         }
+         String [] paths = systemPath.split(File.pathSeparator);
+         if (paths == null) {
+             return;
+         }
+         if (paths.length == 0) {
+             return;
+         }
+         for (int i = 0; i < paths.length; i++) {
+             File path = new File(paths[i]);
+             if (path.exists() && path.isDirectory()) {
+                 File [] files = path.listFiles();
+                 if (files == null) {
+                     continue;
+                 }
+                 if (files.length == 0) {
+                     continue;
+                 }
+                 for (int j = 0; j < files.length; j++) {
+                     File file = files[j];
+                     if (file.canExecute() && file.getName().equals("ptypipe")) {
+                         ptypipeOnPath = true;
+                         return;
+                     }
+                 }
+             }
+         }
+     }
      /**
       * Get the desired window title.
       *
                      }
                  });
              }
-             if (getApplication() != null) {
-                 getApplication().postEvent(new TMenuEvent(
-                     TMenu.MID_REPAINT));
-             }
+             app.doRepaint();
          }
      }
  
          } // synchronized (emulator)
      }
  
+     /**
+      * Wait for a period of time to get output from the launched process.
+      *
+      * @param millis millis to wait for, or 0 to wait forever
+      * @return true if the launched process has emitted something
+      */
+     public boolean waitForOutput(final int millis) {
+         if (emulator == null) {
+             return false;
+         }
+         return emulator.waitForOutput(millis);
+     }
      /**
       * Check if a mouse press/release/motion event coordinate is over the
       * emulator.
       * Called by emulator when fresh data has come in.
       */
      public void displayChanged() {
-         dirty = true;
+         if (emulator != null) {
+             // Force sync here: EMCA48.run() thread might be setting
+             // dirty=true while TTerminalWdiget.draw() is setting
+             // dirty=false.  If these writes start interleaving, the display
+             // stops getting updated.
+             synchronized (emulator) {
+                 dirty = true;
+             }
+         } else {
+             dirty = true;
+         }
          getApplication().postEvent(new TMenuEvent(TMenu.MID_REPAINT));
      }
  
          return 24;
      }
  
+     /**
+      * Get the exit value for the emulator.
+      *
+      * @return exit value
+      */
+     public int getExitValue() {
+         return exitValue;
+     }
+     // ------------------------------------------------------------------------
+     // EditMenuUser -----------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Check if the cut menu item should be enabled.
+      *
+      * @return true if the cut menu item should be enabled
+      */
+     public boolean isEditMenuCut() {
+         return false;
+     }
+     /**
+      * Check if the copy menu item should be enabled.
+      *
+      * @return true if the copy menu item should be enabled
+      */
+     public boolean isEditMenuCopy() {
+         return false;
+     }
+     /**
+      * Check if the paste menu item should be enabled.
+      *
+      * @return true if the paste menu item should be enabled
+      */
+     public boolean isEditMenuPaste() {
+         return true;
+     }
+     /**
+      * Check if the clear menu item should be enabled.
+      *
+      * @return true if the clear menu item should be enabled
+      */
+     public boolean isEditMenuClear() {
+         return false;
+     }
  }
diff --combined TTerminalWindow.java
index e96c50c9921d99603da342e80802c27d0da02e86,754b7a512d6f7581216a78b76c2a8a7be3838dcb..754b7a512d6f7581216a78b76c2a8a7be3838dcb
   */
  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.menu.TMenu;
  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.TCommand.*;
  import static jexer.TKeypress.*;
  
  /**
@@@ -163,10 -142,14 +142,14 @@@ public class TTerminalWindow extends TS
          addShortcutKeys();
  
          // Add shortcut text
-         newStatusBar(i18n.getString("statusBarRunning"));
+         TStatusBar statusBar = newStatusBar(i18n.getString("statusBarRunning"));
+         statusBar.addShortcutKeypress(kbF1, cmHelp,
+             i18n.getString("statusBarHelp"));
+         statusBar.addShortcutKeypress(kbF10, cmMenu,
+             i18n.getString("statusBarMenu"));
  
          // Spin it up
-         terminal = new TTerminalWidget(this, 0, 0, new TAction() {
+         terminal = new TTerminalWidget(this, 0, 0, command, new TAction() {
              public void DO() {
                  onShellExit();
              }
          addShortcutKeys();
  
          // Add shortcut text
-         newStatusBar(i18n.getString("statusBarRunning"));
+         TStatusBar statusBar = newStatusBar(i18n.getString("statusBarRunning"));
+         statusBar.addShortcutKeypress(kbF1, cmHelp,
+             i18n.getString("statusBarHelp"));
+         statusBar.addShortcutKeypress(kbF10, cmMenu,
+             i18n.getString("statusBarMenu"));
  
          // Spin it up
          terminal = new TTerminalWidget(this, 0, 0, new TAction() {
       */
      @Override
      public void onKeypress(final TKeypressEvent keypress) {
-         if ((terminal != null) && (terminal.isReading())) {
+         if ((terminal != null)
+             && (terminal.isReading())
+             && (!inKeyboardResize)
+         ) {
              terminal.onKeypress(keypress);
          } else {
              super.onKeypress(keypress);
          }
      }
  
+     /**
+      * Get this window's help topic to load.
+      *
+      * @return the topic name
+      */
+     @Override
+     public String getHelpTopic() {
+         return "Terminal Window";
+     }
      // ------------------------------------------------------------------------
      // TTerminalWindow --------------------------------------------------------
      // ------------------------------------------------------------------------
          getApplication().postEvent(new TMenuEvent(TMenu.MID_REPAINT));
      }
  
+     /**
+      * Wait for a period of time to get output from the launched process.
+      *
+      * @param millis millis to wait for, or 0 to wait forever
+      * @return true if the launched process has emitted something
+      */
+     public boolean waitForOutput(final int millis) {
+         if (terminal == null) {
+             return false;
+         }
+         return terminal.waitForOutput(millis);
+     }
+     /**
+      * Get the exit value for the emulator.
+      *
+      * @return exit value
+      */
+     public int getExitValue() {
+         if (terminal == null) {
+             return -1;
+         }
+         return terminal.getExitValue();
+     }
  }
index ed22f492a49257944595dc19d53cbb2f3d3d9429,44a19f6809157d15e6098ad24eef59b8db913b80..44a19f6809157d15e6098ad24eef59b8db913b80
@@@ -1,2 -1,4 +1,4 @@@
  windowTitle=Terminal
  statusBarRunning=Terminal session executing...
+ statusBarHelp=Help
+ statusBarMenu=Menu
diff --combined TText.java
index 22bc4b89051d31e586a60b91a274b55d3292bade,f6d7febcc0aefdcd159aa3e5af50d72f1631be0f..f6d7febcc0aefdcd159aa3e5af50d72f1631be0f
@@@ -29,7 -29,7 +29,7 @@@
  package jexer;
  
  import java.util.Arrays;
- import java.util.LinkedList;
+ import java.util.ArrayList;
  import java.util.List;
  
  import jexer.bits.CellAttributes;
@@@ -162,7 -162,7 +162,7 @@@ public class TText extends TScrollableW
          this.text = text;
          this.colorKey = colorKey;
  
-         lines = new LinkedList<String>();
+         lines = new ArrayList<String>();
  
          vScroller = new TVScroller(this, getWidth() - 1, 0,
              Math.max(1, getHeight() - 1));
      /**
       * Set justification.
       *
-      * @param justification LEFT, CENTER, RIGHT, or FULL
+      * @param justification NONE, LEFT, CENTER, RIGHT, or FULL
       */
      public void setJustification(final Justification justification) {
          this.justification = justification;
          reflowData();
      }
  
+     /**
+      * Un-justify the text.
+      */
+     public void unJustify() {
+         justification = Justification.NONE;
+         reflowData();
+     }
  }
diff --combined TWidget.java
index eb06175092d9a8dcd53c20a4d8e75b856d200967,5c93712084a9b6f90bd9728ed17d368de82dfaa4..32ed80656882ec6f6267d042d8c52febbb3fd4dc
@@@ -36,6 -36,7 +36,7 @@@ import java.util.ArrayList
  import jexer.backend.Screen;
  import jexer.bits.Cell;
  import jexer.bits.CellAttributes;
+ import jexer.bits.Clipboard;
  import jexer.bits.ColorTheme;
  import jexer.event.TCommandEvent;
  import jexer.event.TInputEvent;
@@@ -184,7 -185,14 +185,7 @@@ public abstract class TWidget implement
       * @param enabled if true assume enabled
       */
      protected TWidget(final TWidget parent, final boolean enabled) {
 -        this.enabled = enabled;
 -        this.parent = parent;
 -        children = new ArrayList<TWidget>();
 -
 -        if (parent != null) {
 -            this.window = parent.window;
 -            parent.addChild(this);
 -        }
 +        this(parent, enabled, 0, 0, 0, 0);
      }
  
      /**
       * @param command command event
       */
      public void onCommand(final TCommandEvent command) {
-         // Default: do nothing, pass to children instead
-         for (TWidget widget: children) {
-             widget.onCommand(command);
+         if (activeChild != null) {
+             activeChild.onCommand(command);
          }
      }
  
          return null;
      }
  
+     /**
+      * Get the Clipboard.
+      *
+      * @return the Clipboard, or null if not assigned
+      */
+     public Clipboard getClipboard() {
+         if (window != null) {
+             return window.getApplication().getClipboard();
+         }
+         return null;
+     }
      /**
       * Comparison operator.  For various subclasses it sorts on:
       * <ul>
       * @return difference between this.tabOrder and that.tabOrder, or
       * difference between this.z and that.z, or String.compareTo(text)
       */
-     public final int compareTo(final TWidget that) {
 +    @Override
+     public int compareTo(final TWidget that) {
          if ((this instanceof TWindow)
              && (that instanceof TWindow)
          ) {
              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.
                  if (activeChild != null) {
                      activeChild.active = false;
                  }
-                 child.active = true;
-                 activeChild = child;
              }
+             child.active = true;
+             activeChild = child;
          }
      }
  
          return new TRadioGroup(this, x, y, label);
      }
  
+     /**
+      * 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 width width of group
+      * @param label label to display on the group box
+      */
+     public final TRadioGroup addRadioGroup(final int x, final int y,
+         final int width, final String label) {
+         return new TRadioGroup(this, x, y, width, label);
+     }
      /**
       * Convenience function to add a text field to this container/window.
       *
diff --combined TWindow.java
index 58195c915f1ae885485891f159539f53ece7b474,4d14d0eee2debcf23b03e2df314ea41721c38c8c..4d14d0eee2debcf23b03e2df314ea41721c38c8c
@@@ -199,6 -199,11 +199,11 @@@ public class TWindow extends TWidget 
       */
      private boolean hideMouse = false;
  
+     /**
+      * The help topic for this window.
+      */
+     protected String helpTopic = "Help";
      // ------------------------------------------------------------------------
      // Constructors -----------------------------------------------------------
      // ------------------------------------------------------------------------
          }
  
          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));
              // 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;
+             }
+             if (getHeight() + getY() >= getApplication().getDesktopBottom()) {
+                 setHeight(getApplication().getDesktopBottom() - getY());
              }
  
              // Pass a resize event to my children
          this.hideMouse = hideMouse;
      }
  
+     /**
+      * Get this window's help topic to load.
+      *
+      * @return the topic name
+      */
+     public String getHelpTopic() {
+         return helpTopic;
+     }
      /**
       * Generate a human-readable string for this window.
       *
       */
      @Override
      public String toString() {
-         return String.format("%s(%8x) \'%s\' position (%d, %d) geometry %dx%d" +
-             " hidden %s modal %s", getClass().getName(), hashCode(), title,
+         return String.format("%s(%8x) \'%s\' Z %d position (%d, %d) " +
+             "geometry %dx%d  hidden %s modal %s",
+             getClass().getName(), hashCode(), title, getZ(),
              getX(), getY(), getWidth(), getHeight(), hidden, isModal());
      }
  
index e2997d2f6b17486356ddd9d902d114039258bdef,429e698d733177cd59f27095088afd71b4b4ec1f..429e698d733177cd59f27095088afd71b4b4ec1f
@@@ -28,6 -28,9 +28,9 @@@
   */
  package jexer.backend;
  
+ import java.awt.Graphics;
+ import java.awt.Graphics2D;
+ import java.awt.RenderingHints;
  import java.awt.image.BufferedImage;
  import java.io.BufferedReader;
  import java.io.ByteArrayOutputStream;
@@@ -47,10 -50,10 +50,10 @@@ 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.bits.StringUtils;
  import jexer.event.TCommandEvent;
  import jexer.event.TInputEvent;
  import jexer.event.TKeypressEvent;
@@@ -83,6 -86,16 +86,16 @@@ public class ECMA48Terminal extends Log
          MOUSE_SGR,
      }
  
+     /**
+      * Available Jexer images support.
+      */
+     private enum JexerImageOption {
+         DISABLED,
+         JPG,
+         PNG,
+         RGB,
+     }
      // ------------------------------------------------------------------------
      // Variables --------------------------------------------------------------
      // ------------------------------------------------------------------------
       */
      private boolean sixel = true;
  
+     /**
+      * If true, use a single shared palette for sixel.
+      */
+     private boolean sixelSharedPalette = true;
      /**
       * The sixel palette handler.
       */
      private ImageCache iterm2Cache = null;
  
      /**
-      * If true, emit image data via Jexer image protocol.
+      * If not DISABLED, emit image data via Jexer image protocol if the
+      * terminal supports it.
       */
-     private boolean jexerImages = false;
+     private JexerImageOption jexerImageOption = JexerImageOption.JPG;
  
      /**
       * 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.
       */
  
          // 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());
  
+         this.output.flush();
          // Query the screen size
          sessionInfo.queryWindowSize();
          setDimensions(sessionInfo.getWindowWidth(),
  
          // 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());
  
+         this.output.flush();
          // Query the screen size
          sessionInfo.queryWindowSize();
          setDimensions(sessionInfo.getWindowWidth(),
              // SQUASH
          }
  
-         // Default to using images for full-width characters.
+         // Shared palette
+         if (System.getProperty("jexer.ECMA48.sixelSharedPalette",
+                 "true").equals("false")) {
+             sixelSharedPalette = false;
+         } else {
+             sixelSharedPalette = true;
+         }
+         // Default to not supporting iTerm2 images.
          if (System.getProperty("jexer.ECMA48.iTerm2Images",
                  "false").equals("true")) {
              iterm2Images = true;
              iterm2Images = false;
          }
  
+         // Default to using JPG Jexer images if terminal supports it.
+         String jexerImageStr = System.getProperty("jexer.ECMA48.jexerImages",
+             "jpg").toLowerCase();
+         if (jexerImageStr.equals("false")) {
+             jexerImageOption = JexerImageOption.DISABLED;
+         } else if (jexerImageStr.equals("jpg")) {
+             jexerImageOption = JexerImageOption.JPG;
+         } else if (jexerImageStr.equals("png")) {
+             jexerImageOption = JexerImageOption.PNG;
+         } else if (jexerImageStr.equals("rgb")) {
+             jexerImageOption = JexerImageOption.RGB;
+         }
          // Set custom colors
          setCustomSystemColors();
      }
       * @return the width in pixels of a character cell
       */
      public int getTextWidth() {
-         return (widthPixels / sessionInfo.getWindowWidth());
+         if (sessionInfo.getWindowWidth() > 0) {
+             return (widthPixels / sessionInfo.getWindowWidth());
+         }
+         return 16;
      }
  
      /**
       * @return the height in pixels of a character cell
       */
      public int getTextHeight() {
-         return (heightPixels / sessionInfo.getWindowHeight());
+         if (sessionInfo.getWindowHeight() > 0) {
+             return (heightPixels / sessionInfo.getWindowHeight());
+         }
+         return 20;
      }
  
      /**
                  if (cellsToDraw.size() > 0) {
                      if (iterm2Images) {
                          sb.append(toIterm2Image(x, y, cellsToDraw));
-                     } else if (jexerImages) {
+                     } else if (jexerImageOption != JexerImageOption.DISABLED) {
                          sb.append(toJexerImage(x, y, cellsToDraw));
                      } else {
                          sb.append(toSixel(x, y, cellsToDraw));
          boolean eventMouse3 = false;
          boolean eventMouseWheelUp = false;
          boolean eventMouseWheelDown = false;
+         boolean eventAlt = false;
+         boolean eventCtrl = false;
+         boolean eventShift = false;
  
          // System.err.printf("buttons: %04x\r\n", buttons);
  
-         switch (buttons) {
+         switch (buttons & 0xE3) {
          case 0:
              eventMouse1 = true;
              mouse1 = true;
              eventType = TMouseEvent.Type.MOUSE_MOTION;
              break;
          }
+         if ((buttons & 0x04) != 0) {
+             eventShift = true;
+         }
+         if ((buttons & 0x08) != 0) {
+             eventAlt = true;
+         }
+         if ((buttons & 0x10) != 0) {
+             eventCtrl = true;
+         }
          return new TMouseEvent(eventType, x, y, x, y,
              eventMouse1, eventMouse2, eventMouse3,
-             eventMouseWheelUp, eventMouseWheelDown);
+             eventMouseWheelUp, eventMouseWheelDown,
+             eventAlt, eventCtrl, eventShift);
      }
  
      /**
          boolean eventMouse3 = false;
          boolean eventMouseWheelUp = false;
          boolean eventMouseWheelDown = false;
+         boolean eventAlt = false;
+         boolean eventCtrl = false;
+         boolean eventShift = false;
  
          if (release) {
              eventType = TMouseEvent.Type.MOUSE_UP;
          }
  
-         switch (buttons) {
+         switch (buttons & 0xE3) {
          case 0:
              eventMouse1 = true;
              break;
              // Unknown, bail out
              return null;
          }
+         if ((buttons & 0x04) != 0) {
+             eventShift = true;
+         }
+         if ((buttons & 0x08) != 0) {
+             eventAlt = true;
+         }
+         if ((buttons & 0x10) != 0) {
+             eventCtrl = true;
+         }
          return new TMouseEvent(eventType, x, y, x, y,
              eventMouse1, eventMouse2, eventMouse3,
-             eventMouseWheelUp, eventMouseWheelDown);
+             eventMouseWheelUp, eventMouseWheelDown,
+             eventAlt, eventCtrl, eventShift);
      }
  
      /**
                      if (decPrivateModeFlag == false) {
                          break;
                      }
+                     boolean reportsJexerImages = false;
+                     boolean reportsIterm2Images = false;
                      for (String x: params) {
                          if (x.equals("4")) {
                              // Terminal reports sixel support
                              if (debugToStderr) {
                                  System.err.println("Device Attributes: Jexer images");
                              }
-                             jexerImages = true;
+                             reportsJexerImages = true;
+                         }
+                         if (x.equals("1337")) {
+                             // Terminal reports iTerm2 images support
+                             if (debugToStderr) {
+                                 System.err.println("Device Attributes: iTerm2 images");
+                             }
+                             reportsIterm2Images = true;
                          }
                      }
+                     if (reportsJexerImages == false) {
+                         // Terminal does not support Jexer images, disable
+                         // them.
+                         jexerImageOption = JexerImageOption.DISABLED;
+                     }
+                     if (reportsIterm2Images == false) {
+                         // Terminal does not support iTerm2 images, disable
+                         // them.
+                         iterm2Images = false;
+                     }
+                     resetParser();
                      return;
                  case 't':
                      // windowOps
       *   - enable sixel scrolling
       *
       *   - disable private color registers (so that we can use one common
-      *     palette)
+      *     palette) if sixelSharedPalette is set
       *
       * @return the string to emit to xterm
       */
      private String xtermSetSixelSettings() {
-         return "\033[?80h\033[?1070l";
+         if (sixelSharedPalette == true) {
+             return "\033[?80h\033[?1070l";
+         } else {
+             return "\033[?80h\033[?1070h";
+         }
      }
  
      /**
  
          if (palette == null) {
              palette = new SixelPalette();
-             // TODO: make this an option (shared palette or not)
-             palette.emitPalette(sb, null);
+             if (sixelSharedPalette == true) {
+                 palette.emitPalette(sb, null);
+             }
          }
  
          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.
+             // we draw any pixels here.  Do not draw the image, bail out
+             // instead.
              sb.append(normal());
              sb.append(gotoXY(x, y));
              for (int j = 0; j < cells.size(); j++) {
              // 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);
-                 }
-             }
-         }
+         BufferedImage image = cellsToImage(cells);
+         int fullHeight = image.getHeight();
  
          // 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);
+             if (sixelSharedPalette == true) {
+                 palette.emitPalette(sb, null);
+             }
          }
          image = palette.ditherImage(image);
  
          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;
+         if (sixelSharedPalette == false) {
+             // Emit the palette, but only for the colors actually used by
+             // these cells.
+             boolean [] usedColors = new boolean[sixelPaletteSize];
+             for (int imageX = 0; imageX < image.getWidth(); imageX++) {
+                 for (int imageY = 0; imageY < image.getHeight(); imageY++) {
+                     usedColors[image.getRGB(imageX, imageY)] = true;
+                 }
              }
+             palette.emitPalette(sb, usedColors);
          }
-         palette.emitPalette(sb, usedColors);
-          */
  
          // Render the entire row of cells.
          for (int currentRow = 0; currentRow < fullHeight; currentRow += 6) {
          return sixel;
      }
  
-     // ------------------------------------------------------------------------
-     // End sixel output support -----------------------------------------------
-     // ------------------------------------------------------------------------
-     // ------------------------------------------------------------------------
-     // iTerm2 image output support --------------------------------------------
-     // ------------------------------------------------------------------------
      /**
-      * Create an iTerm2 images string representing a row of several cells
-      * containing bitmap data.
+      * Convert a horizontal range of cell's image data into a single
+      * contigous image, rescaled and anti-aliased to match the current text
+      * cell size.
       *
-      * @param 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
+      * @param cells the cells containing image data
+      * @return the image resized to the current text cell size
       */
-     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");
-         }
+     private BufferedImage cellsToImage(final List<Cell> cells) {
          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
+         // Piece cells.get(x).getImage() pieces together into one larger
          // image for final rendering.
          int totalWidth = 0;
-         int fullWidth = cells.size() * getTextWidth();
-         int fullHeight = getTextHeight();
+         int fullWidth = cells.size() * imageWidth;
+         int fullHeight = imageHeight;
          for (int i = 0; i < cells.size(); i++) {
              totalWidth += cells.get(i).getImage().getWidth();
          }
  
          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);
+             int tileWidth = imageWidth;
+             int tileHeight = imageHeight;
              if (false && cells.get(i).isInvertedImage()) {
                  // I used to put an all-white cell over the cursor, don't do
                  // that anymore.
          image.setRGB((cells.size() - 1) * imageWidth, 0, totalWidth,
              imageHeight, rgbArray, 0, totalWidth);
  
-         if (totalWidth < getTextWidth()) {
+         if (totalWidth < imageWidth) {
              int backgroundColor = cells.get(cells.size() - 1).getBackground().getRGB();
  
              for (int imageX = image.getWidth() - totalWidth;
              }
          }
  
+         if ((image.getWidth() != cells.size() * getTextWidth())
+             || (image.getHeight() != getTextHeight())
+         ) {
+             // Rescale the image to fit the text cells it is going into.
+             BufferedImage newImage;
+             newImage = new BufferedImage(cells.size() * getTextWidth(),
+                 getTextHeight(), BufferedImage.TYPE_INT_ARGB);
+             Graphics gr = newImage.getGraphics();
+             if (gr instanceof Graphics2D) {
+                 ((Graphics2D) gr).setRenderingHint(RenderingHints.KEY_ANTIALIASING,
+                     RenderingHints.VALUE_ANTIALIAS_ON);
+                 ((Graphics2D) gr).setRenderingHint(RenderingHints.KEY_RENDERING,
+                     RenderingHints.VALUE_RENDER_QUALITY);
+             }
+             gr.drawImage(image, 0, 0, newImage.getWidth(),
+                 newImage.getHeight(), null, null);
+             gr.dispose();
+             image = newImage;
+         }
+         return image;
+     }
+     // ------------------------------------------------------------------------
+     // End sixel output support -----------------------------------------------
+     // ------------------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     // iTerm2 image output support --------------------------------------------
+     // ------------------------------------------------------------------------
+     /**
+      * Create an iTerm2 images string representing a row of several cells
+      * containing bitmap data.
+      *
+      * @param x column coordinate.  0 is the left-most column.
+      * @param y row coordinate.  0 is the top-most row.
+      * @param cells the cells containing the bitmap data
+      * @return the string to emit to an ANSI / ECMA-style terminal
+      */
+     private String toIterm2Image(final int x, final int y,
+         final ArrayList<Cell> cells) {
+         StringBuilder sb = new StringBuilder();
+         assert (cells != null);
+         assert (cells.size() > 0);
+         assert (cells.get(0).getImage() != null);
+         if (iterm2Images == false) {
+             sb.append(normal());
+             sb.append(gotoXY(x, y));
+             for (int i = 0; i < cells.size(); i++) {
+                 sb.append(' ');
+             }
+             return sb.toString();
+         }
+         if (iterm2Cache == null) {
+             iterm2Cache = new ImageCache(height * 10);
+         }
+         // Save and get rows to/from the cache that do NOT have inverted
+         // cells.
+         boolean saveInCache = true;
+         for (Cell cell: cells) {
+             if (cell.isInvertedImage()) {
+                 saveInCache = false;
+             }
+         }
+         if (saveInCache) {
+             String cachedResult = iterm2Cache.get(cells);
+             if (cachedResult != null) {
+                 // System.err.println("CACHE HIT");
+                 sb.append(gotoXY(x, y));
+                 sb.append(cachedResult);
+                 return sb.toString();
+             }
+             // System.err.println("CACHE MISS");
+         }
+         BufferedImage image = cellsToImage(cells);
+         int fullHeight = image.getHeight();
          /*
           * From https://iterm2.com/documentation-images.html:
           *
              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;",
                      getTextHeight())));
           */
          sb.append("inline=1:");
-         sb.append(base64.encodeToString(pngOutputStream.toByteArray()));
+         sb.append(StringUtils.toBase64(pngOutputStream.toByteArray()));
          sb.append("\007");
  
          if (saveInCache) {
          assert (cells.size() > 0);
          assert (cells.get(0).getImage() != null);
  
-         if (jexerImages == false) {
+         if (jexerImageOption == JexerImageOption.DISABLED) {
              sb.append(normal());
              sb.append(gotoXY(x, y));
              for (int i = 0; i < cells.size(); i++) {
  
          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
              // 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);
+         BufferedImage image = cellsToImage(cells);
+         int fullHeight = image.getHeight();
  
-         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);
+         if (jexerImageOption == JexerImageOption.PNG) {
+             // Encode as PNG
+             ByteArrayOutputStream pngOutputStream = new ByteArrayOutputStream(1024);
+             try {
+                 if (!ImageIO.write(image.getSubimage(0, 0, image.getWidth(),
+                             Math.min(image.getHeight(), fullHeight)),
+                         "PNG", pngOutputStream)
+                 ) {
+                     // We failed to render image, bail out.
+                     return "";
                  }
+             } catch (IOException e) {
+                 // We failed to render image, bail out.
+                 return "";
              }
  
-             /*
-             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++) {
+             sb.append("\033]444;1;0;");
+             sb.append(StringUtils.toBase64(pngOutputStream.toByteArray()));
+             sb.append("\007");
  
-                         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);
+         } else if (jexerImageOption == JexerImageOption.JPG) {
  
-         if (totalWidth < getTextWidth()) {
-             int backgroundColor = cells.get(cells.size() - 1).getBackground().getRGB();
+             // Encode as JPG
+             ByteArrayOutputStream jpgOutputStream = new ByteArrayOutputStream(1024);
  
-             for (int imageX = image.getWidth() - totalWidth;
-                  imageX < image.getWidth(); imageX++) {
+             // Convert from ARGB to RGB, otherwise the JPG encode will fail.
+             BufferedImage jpgImage = new BufferedImage(image.getWidth(),
+                 image.getHeight(), BufferedImage.TYPE_INT_RGB);
+             int [] pixels = new int[image.getWidth() * image.getHeight()];
+             image.getRGB(0, 0, image.getWidth(), image.getHeight(), pixels,
+                 0, image.getWidth());
+             jpgImage.setRGB(0, 0, image.getWidth(), image.getHeight(), pixels,
+                 0, image.getWidth());
  
-                 for (int imageY = 0; imageY < fullHeight; imageY++) {
-                     image.setRGB(imageX, imageY, backgroundColor);
+             try {
+                 if (!ImageIO.write(jpgImage.getSubimage(0, 0,
+                             jpgImage.getWidth(),
+                             Math.min(jpgImage.getHeight(), fullHeight)),
+                         "JPG", jpgOutputStream)
+                 ) {
+                     // We failed to render image, bail out.
+                     return "";
                  }
+             } catch (IOException e) {
+                 // We failed to render image, bail out.
+                 return "";
              }
-         }
  
-         sb.append(String.format("\033]444;%d;%d;0;", image.getWidth(),
-                 Math.min(image.getHeight(), fullHeight)));
+             sb.append("\033]444;2;0;");
+             sb.append(StringUtils.toBase64(jpgOutputStream.toByteArray()));
+             sb.append("\007");
+         } else if (jexerImageOption == JexerImageOption.RGB) {
+             // RGB
+             sb.append(String.format("\033]444;0;%d;%d;0;", image.getWidth(),
+                     Math.min(image.getHeight(), fullHeight)));
  
-         byte [] bytes = new byte[image.getWidth() * image.getHeight() * 3];
-         int stride = image.getWidth();
-         for (int px = 0; px < stride; px++) {
-             for (int py = 0; py < image.getHeight(); py++) {
-                 int rgb = image.getRGB(px, py);
-                 bytes[(py * stride * 3) + (px * 3)]     = (byte) ((rgb >>> 16) & 0xFF);
-                 bytes[(py * stride * 3) + (px * 3) + 1] = (byte) ((rgb >>>  8) & 0xFF);
-                 bytes[(py * stride * 3) + (px * 3) + 2] = (byte) ( rgb         & 0xFF);
+             byte [] bytes = new byte[image.getWidth() * image.getHeight() * 3];
+             int stride = image.getWidth();
+             for (int px = 0; px < stride; px++) {
+                 for (int py = 0; py < image.getHeight(); py++) {
+                     int rgb = image.getRGB(px, py);
+                     bytes[(py * stride * 3) + (px * 3)]     = (byte) ((rgb >>> 16) & 0xFF);
+                     bytes[(py * stride * 3) + (px * 3) + 1] = (byte) ((rgb >>>  8) & 0xFF);
+                     bytes[(py * stride * 3) + (px * 3) + 2] = (byte) ( rgb         & 0xFF);
+                 }
              }
+             sb.append(StringUtils.toBase64(bytes));
+             sb.append("\007");
          }
-         sb.append(base64.encodeToString(bytes));
-         sb.append("\007");
  
          if (saveInCache) {
              // This row is OK to save into the cache.
       * @return true if this terminal is emitting Jexer images
       */
      public boolean hasJexerImages() {
-         return jexerImages;
+         return (jexerImageOption != JexerImageOption.DISABLED);
      }
  
      // ------------------------------------------------------------------------
diff --combined backend/GlyphMaker.java
index 0da2918def6c8d9d0f57d7a77506828039994d69,e5fcc522da927fb2c8788b98f8a5075815389f38..e5fcc522da927fb2c8788b98f8a5075815389f38
@@@ -29,6 -29,7 +29,7 @@@
  package jexer.backend;
  
  import java.awt.Font;
+ import java.awt.FontFormatException;
  import java.awt.FontMetrics;
  import java.awt.Graphics2D;
  import java.awt.geom.Rectangle2D;
@@@ -139,7 -140,7 +140,7 @@@ class GlyphMakerFont 
  
          if (filename.length() == 0) {
              // Fallback font
-             font = new Font(Font.MONOSPACED, Font.PLAIN, fontSize);
+             font = new Font(Font.MONOSPACED, Font.PLAIN, fontSize - 2);
              return;
          }
  
              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) {
+             font = fontRoot.deriveFont(Font.PLAIN, fontSize - 2);
+         } catch (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) {
+             font = new Font(Font.MONOSPACED, Font.PLAIN, fontSize - 2);
+         } catch (IOException e) {
              // See comment above.
-             font = new Font(Font.MONOSPACED, Font.PLAIN, fontSize);
+             font = new Font(Font.MONOSPACED, Font.PLAIN, fontSize - 2);
          }
      }
  
index 4e4aecca7a349eb999b83a9ef4bc71c229ff650a,22b7e95f6564aad97431ca2e60a2fec09e9d2365..22b7e95f6564aad97431ca2e60a2fec09e9d2365
@@@ -33,6 -33,7 +33,7 @@@ import java.awt.image.BufferedImage
  import jexer.backend.GlyphMaker;
  import jexer.bits.Cell;
  import jexer.bits.CellAttributes;
+ import jexer.bits.Clipboard;
  import jexer.bits.GraphicsChars;
  import jexer.bits.StringUtils;
  
@@@ -1042,4 -1043,184 +1043,184 @@@ public class LogicalScreen implements S
          putFullwidthCharXY(x, y, cell);
      }
  
+     /**
+      * Invert the cell color at a position, including both halves of a
+      * double-width cell.
+      *
+      * @param x column position
+      * @param y row position
+      */
+     public void invertCell(final int x, final int y) {
+         invertCell(x, y, false);
+     }
+     /**
+      * Invert the cell color at a position.
+      *
+      * @param x column position
+      * @param y row position
+      * @param onlyThisCell if true, only invert this cell, otherwise invert
+      * both halves of a double-width cell if necessary
+      */
+     public void invertCell(final int x, final int y,
+         final boolean onlyThisCell) {
+         Cell cell = 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);
+         }
+         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 < width - 1) {
+                 Cell rightHalf = 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 = getCharXY(x - 1, y);
+                 if (leftHalf.getWidth() == Cell.Width.LEFT) {
+                     invertCell(x - 1, y, true);
+                 }
+             }
+         }
+     }
+     /**
+      * Set a selection area on the screen.
+      *
+      * @param x0 the starting X position of the selection
+      * @param y0 the starting Y position of the selection
+      * @param x1 the ending X position of the selection
+      * @param y1 the ending Y position of the selection
+      * @param rectangle if true, this is a rectangle select
+      */
+     public void setSelection(final int x0, final int y0,
+         final int x1, final int y1, final boolean rectangle) {
+         int startX = x0;
+         int startY = y0;
+         int endX = x1;
+         int endY = y1;
+         if (((x1 < x0) && (y1 == y0))
+             || (y1 < y0)
+         ) {
+             // The user dragged from bottom-to-top and/or right-to-left.
+             // Reverse the coordinates for the inverted section.
+             startX = x1;
+             startY = y1;
+             endX = x0;
+             endY = y0;
+         }
+         if (rectangle) {
+             for (int y = startY; y <= endY; y++) {
+                 for (int x = startX; x <= endX; x++) {
+                     invertCell(x, y);
+                 }
+             }
+         } else {
+             if (endY > startY) {
+                 for (int x = startX; x < width; x++) {
+                     invertCell(x, startY);
+                 }
+                 for (int y = startY + 1; y < endY; y++) {
+                     for (int x = 0; x < width; x++) {
+                         invertCell(x, y);
+                     }
+                 }
+                 for (int x = 0; x <= endX; x++) {
+                     invertCell(x, endY);
+                 }
+             } else {
+                 assert (startY == endY);
+                 for (int x = startX; x <= endX; x++) {
+                     invertCell(x, startY);
+                 }
+             }
+         }
+     }
+     /**
+      * Copy the screen selection area to the clipboard.
+      *
+      * @param clipboard the clipboard to use
+      * @param x0 the starting X position of the selection
+      * @param y0 the starting Y position of the selection
+      * @param x1 the ending X position of the selection
+      * @param y1 the ending Y position of the selection
+      * @param rectangle if true, this is a rectangle select
+      */
+     public void copySelection(final Clipboard clipboard,
+         final int x0, final int y0, final int x1, final int y1,
+         final boolean rectangle) {
+         StringBuilder sb = new StringBuilder();
+         int startX = x0;
+         int startY = y0;
+         int endX = x1;
+         int endY = y1;
+         if (((x1 < x0) && (y1 == y0))
+             || (y1 < y0)
+         ) {
+             // The user dragged from bottom-to-top and/or right-to-left.
+             // Reverse the coordinates for the inverted section.
+             startX = x1;
+             startY = y1;
+             endX = x0;
+             endY = y0;
+         }
+         if (rectangle) {
+             for (int y = startY; y <= endY; y++) {
+                 for (int x = startX; x <= endX; x++) {
+                     sb.append(Character.toChars(getCharXY(x, y).getChar()));
+                 }
+                 sb.append("\n");
+             }
+         } else {
+             if (endY > startY) {
+                 for (int x = startX; x < width; x++) {
+                     sb.append(Character.toChars(getCharXY(x, startY).getChar()));
+                 }
+                 sb.append("\n");
+                 for (int y = startY + 1; y < endY; y++) {
+                     for (int x = 0; x < width; x++) {
+                         sb.append(Character.toChars(getCharXY(x, y).getChar()));
+                     }
+                     sb.append("\n");
+                 }
+                 for (int x = 0; x <= endX; x++) {
+                     sb.append(Character.toChars(getCharXY(x, endY).getChar()));
+                 }
+             } else {
+                 assert (startY == endY);
+                 for (int x = startX; x <= endX; x++) {
+                     sb.append(Character.toChars(getCharXY(x, startY).getChar()));
+                 }
+             }
+         }
+         clipboard.copyText(sb.toString());
+     }
  }
diff --combined backend/MultiScreen.java
index 9d66b69342896a50c6d8683d6cc4c463db4491c2,45741c05f15853eb336738267508cd1fd62e32ee..45741c05f15853eb336738267508cd1fd62e32ee
@@@ -33,6 -33,7 +33,7 @@@ import java.util.List
  
  import jexer.bits.Cell;
  import jexer.bits.CellAttributes;
+ import jexer.bits.Clipboard;
  
  /**
   * MultiScreen mirrors its I/O to several screens.
@@@ -93,7 -94,10 +94,10 @@@ public class MultiScreen implements Scr
       * @return drawing boundary
       */
      public int getClipRight() {
-         return screens.get(0).getClipRight();
+         if (screens.size() > 0) {
+             return screens.get(0).getClipRight();
+         }
+         return 0;
      }
  
      /**
       * @return drawing boundary
       */
      public int getClipBottom() {
-         return screens.get(0).getClipBottom();
+         if (screens.size() > 0) {
+             return screens.get(0).getClipBottom();
+         }
+         return 0;
      }
  
      /**
       * @return drawing boundary
       */
      public int getClipLeft() {
-         return screens.get(0).getClipLeft();
+         if (screens.size() > 0) {
+             return screens.get(0).getClipLeft();
+         }
+         return 0;
      }
  
      /**
       * @return drawing boundary
       */
      public int getClipTop() {
-         return screens.get(0).getClipTop();
+         if (screens.size() > 0) {
+             return screens.get(0).getClipTop();
+         }
+         return 0;
      }
  
      /**
       * @return attributes at (x, y)
       */
      public CellAttributes getAttrXY(final int x, final int y) {
-         return screens.get(0).getAttrXY(x, y);
+         if (screens.size() > 0) {
+             return screens.get(0).getAttrXY(x, y);
+         }
+         return new CellAttributes();
      }
  
      /**
       * @return the character + attributes
       */
      public Cell getCharXY(final int x, final int y) {
-         return screens.get(0).getCharXY(x, y);
+         if (screens.size() > 0) {
+             return screens.get(0).getCharXY(x, y);
+         }
+         return new Cell();
      }
  
      /**
       */
      public int getHeight() {
          // Return the smallest height of the screens.
-         int height = screens.get(0).getHeight();
+         int height = 25;
+         if (screens.size() > 0) {
+             height = screens.get(0).getHeight();
+         }
          for (Screen screen: screens) {
              if (screen.getHeight() < height) {
                  height = screen.getHeight();
       */
      public int getWidth() {
          // Return the smallest width of the screens.
-         int width = screens.get(0).getWidth();
+         int width = 80;
+         if (screens.size() > 0) {
+             width = screens.get(0).getWidth();
+         }
          for (Screen screen: screens) {
              if (screen.getWidth() < width) {
                  width = screen.getWidth();
       * @return true if the cursor is visible
       */
      public boolean isCursorVisible() {
-         return screens.get(0).isCursorVisible();
+         if (screens.size() > 0) {
+             return screens.get(0).isCursorVisible();
+         }
+         return true;
      }
  
      /**
       * @return the cursor x column position
       */
      public int getCursorX() {
-         return screens.get(0).getCursorX();
+         if (screens.size() > 0) {
+             return screens.get(0).getCursorX();
+         }
+         return 0;
      }
  
      /**
       * @return the cursor y row position
       */
      public int getCursorY() {
-         return screens.get(0).getCursorY();
+         if (screens.size() > 0) {
+             return screens.get(0).getCursorY();
+         }
+         return 0;
      }
  
      /**
          return textHeight;
      }
  
+     /**
+      * Invert the cell color at a position, including both halves of a
+      * double-width cell.
+      *
+      * @param x column position
+      * @param y row position
+      */
+     public void invertCell(final int x, final int y) {
+         for (Screen screen: screens) {
+             screen.invertCell(x, y);
+         }
+     }
+     /**
+      * Invert the cell color at a position.
+      *
+      * @param x column position
+      * @param y row position
+      * @param onlyThisCell if true, only invert this cell, otherwise invert
+      * both halves of a double-width cell if necessary
+      */
+     public void invertCell(final int x, final int y,
+         final boolean onlyThisCell) {
+         for (Screen screen: screens) {
+             screen.invertCell(x, y, onlyThisCell);
+         }
+     }
+     /**
+      * Set a selection area on the screen.
+      *
+      * @param x0 the starting X position of the selection
+      * @param y0 the starting Y position of the selection
+      * @param x1 the ending X position of the selection
+      * @param y1 the ending Y position of the selection
+      * @param rectangle if true, this is a rectangle select
+      */
+     public void setSelection(final int x0, final int y0,
+         final int x1, final int y1, final boolean rectangle) {
+         for (Screen screen: screens) {
+             screen.setSelection(x0, y0, x1, y1, rectangle);
+         }
+     }
+     /**
+      * Copy the screen selection area to the clipboard.
+      *
+      * @param clipboard the clipboard to use
+      * @param x0 the starting X position of the selection
+      * @param y0 the starting Y position of the selection
+      * @param x1 the ending X position of the selection
+      * @param y1 the ending Y position of the selection
+      * @param rectangle if true, this is a rectangle select
+      */
+     public void copySelection(final Clipboard clipboard,
+         final int x0, final int y0, final int x1, final int y1,
+         final boolean rectangle) {
+         // Only copy from the first screen.
+         if (screens.size() > 0) {
+             screens.get(0).copySelection(clipboard, x0, y0, x1, y1, rectangle);
+         }
+     }
  }
diff --combined backend/Screen.java
index 2a71073176a6608b3a35d0740855e187c746d0e0,a9a2053565b7bf5dc8ba02f25cfce37e9453c714..a9a2053565b7bf5dc8ba02f25cfce37e9453c714
@@@ -30,6 -30,7 +30,7 @@@ package jexer.backend
  
  import jexer.bits.Cell;
  import jexer.bits.CellAttributes;
+ import jexer.bits.Clipboard;
  
  /**
   * Drawing operations API.
@@@ -409,4 -410,50 +410,50 @@@ public interface Screen 
       */
      public int getTextHeight();
  
+     /**
+      * Invert the cell color at a position, including both halves of a
+      * double-width cell.
+      *
+      * @param x column position
+      * @param y row position
+      */
+     public void invertCell(final int x, final int y);
+     /**
+      * Invert the cell color at a position.
+      *
+      * @param x column position
+      * @param y row position
+      * @param onlyThisCell if true, only invert this cell, otherwise invert
+      * both halves of a double-width cell if necessary
+      */
+     public void invertCell(final int x, final int y,
+         final boolean onlyThisCell);
+     /**
+      * Set a selection area on the screen.
+      *
+      * @param x0 the starting X position of the selection
+      * @param y0 the starting Y position of the selection
+      * @param x1 the ending X position of the selection
+      * @param y1 the ending Y position of the selection
+      * @param rectangle if true, this is a rectangle select
+      */
+     public void setSelection(final int x0, final int y0,
+         final int x1, final int y1, final boolean rectangle);
+     /**
+      * Copy the screen selection area to the clipboard.
+      *
+      * @param clipboard the clipboard to use
+      * @param x0 the starting X position of the selection
+      * @param y0 the starting Y position of the selection
+      * @param x1 the ending X position of the selection
+      * @param y1 the ending Y position of the selection
+      * @param rectangle if true, this is a rectangle select
+      */
+     public void copySelection(final Clipboard clipboard,
+         final int x0, final int y0, final int x1, final int y1,
+         final boolean rectangle);
  }
index 3d1074cf889070d6bc1c7eee4d9be1da80b92d06,df3633398b699585757cab519b1102214983a50c..df3633398b699585757cab519b1102214983a50c
@@@ -83,7 -83,7 +83,7 @@@ class SwingComponent 
       * 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);
+     Insets adjustInsets = null;
  
      // ------------------------------------------------------------------------
      // Constructors -----------------------------------------------------------
       */
      public SwingComponent(final JFrame frame) {
          this.frame = frame;
+         if (System.getProperty("os.name").startsWith("Linux")) {
+             // On my Linux dev system, a Swing frame draws its contents just
+             // a little off.  No idea why, but I've seen it on both Debian
+             // and Fedora with KDE.  These adjustments to the adjustments
+             // seem to center it OK in the frame.
+             adjustInsets = new Insets(BORDER + 5, BORDER,
+                 BORDER - 3, BORDER + 2);
+         } else {
+             adjustInsets = new Insets(BORDER, BORDER, BORDER, BORDER);
+         }
          setupFrame();
      }
  
       */
      public SwingComponent(final JComponent component) {
          this.component = component;
+         adjustInsets = new Insets(BORDER, BORDER, BORDER, BORDER);
          setupComponent();
      }
  
index f0ba3552fd52b812a91a06be0f97c9adb96604e7,0727efc894d5832dc515fa1342d622a2eac885df..0727efc894d5832dc515fa1342d622a2eac885df
@@@ -36,6 -36,7 +36,7 @@@ import java.awt.Graphics2D
  import java.awt.Graphics;
  import java.awt.Insets;
  import java.awt.Rectangle;
+ import java.awt.RenderingHints;
  import java.awt.Toolkit;
  import java.awt.event.ComponentEvent;
  import java.awt.event.ComponentListener;
@@@ -578,14 -579,12 +579,12 @@@ public class SwingTerminal extends Logi
          ) {
              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();
  
          // Draw the background rectangle, then the foreground character.
          assert (cell.isImage());
+         // Enable anti-aliasing
+         if (gr instanceof Graphics2D) {
+             ((Graphics2D) gr).setRenderingHint(RenderingHints.KEY_ANTIALIASING,
+                 RenderingHints.VALUE_ANTIALIAS_ON);
+             ((Graphics2D) gr).setRenderingHint(RenderingHints.KEY_RENDERING,
+                 RenderingHints.VALUE_RENDER_QUALITY);
+         }
          gr.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());
+                 gr.drawImage(image, xPixel, yPixel, getTextWidth(),
+                     getTextHeight(), swing.getFrame());
              } else {
-                 gr.drawImage(image, xPixel, yPixel, swing.getComponent());
+                 gr.drawImage(image, xPixel, yPixel,  getTextWidth(),
+                     getTextHeight(),swing.getComponent());
              }
              return;
          }
              cellColor.setBackColor(cell.getForeColor());
          }
  
+         // Enable anti-aliasing
+         if ((gr instanceof Graphics2D) && (swing.getFrame() != null)) {
+             // Anti-aliasing on JComponent makes the hash character disappear
+             // for Terminus font, and also kills performance.  Only enable it
+             // for JFrame.
+             ((Graphics2D) gr).setRenderingHint(RenderingHints.KEY_ANTIALIASING,
+                 RenderingHints.VALUE_ANTIALIAS_ON);
+             ((Graphics2D) gr).setRenderingHint(RenderingHints.KEY_RENDERING,
+                 RenderingHints.VALUE_RENDER_QUALITY);
+         }
          // Draw the background rectangle, then the foreground character.
          gr2.setColor(attrToBackgroundColor(cellColor));
          gr2.fillRect(gr2x, gr2y, textWidth, textHeight);
          } else {
              ch = key.getKeyChar();
          }
-         alt = key.isAltDown();
+         // Both meta and alt count as alt, thanks to Mac using alt for
+         // "symbols" so meta ("command") is the only other modifier left.
+         alt = key.isAltDown() | key.isMetaDown();
          ctrl = key.isControlDown();
          shift = key.isShiftDown();
  
          /*
          System.err.printf("Swing Key: %s\n", key);
          System.err.printf("   isKey: %s\n", isKey);
+         System.err.printf("   meta: %s\n", key.isMetaDown());
          System.err.printf("   alt: %s\n", alt);
          System.err.printf("   ctrl: %s\n", ctrl);
          System.err.printf("   shift: %s\n", shift);
          boolean eventMouse1 = false;
          boolean eventMouse2 = false;
          boolean eventMouse3 = false;
+         boolean eventAlt = false;
+         boolean eventCtrl = false;
+         boolean eventShift = false;
          if ((modifiers & MouseEvent.BUTTON1_DOWN_MASK) != 0) {
              eventMouse1 = true;
          }
          if ((modifiers & MouseEvent.BUTTON3_DOWN_MASK) != 0) {
              eventMouse3 = true;
          }
+         if ((modifiers & MouseEvent.ALT_DOWN_MASK) != 0) {
+             eventAlt = true;
+         }
+         if ((modifiers & MouseEvent.CTRL_DOWN_MASK) != 0) {
+             eventCtrl = true;
+         }
+         if ((modifiers & MouseEvent.SHIFT_DOWN_MASK) != 0) {
+             eventShift = true;
+         }
          mouse1 = eventMouse1;
          mouse2 = eventMouse2;
          mouse3 = eventMouse3;
          int y = textRow(mouse.getY());
  
          TMouseEvent mouseEvent = new TMouseEvent(TMouseEvent.Type.MOUSE_MOTION,
-             x, y, x, y, mouse1, mouse2, mouse3, false, false);
+             x, y, x, y, mouse1, mouse2, mouse3, false, false,
+             eventAlt, eventCtrl, eventShift);
  
          synchronized (eventQueue) {
              eventQueue.add(mouseEvent);
          oldMouseX = x;
          oldMouseY = y;
  
+         boolean eventAlt = false;
+         boolean eventCtrl = false;
+         boolean eventShift = false;
+         int modifiers = mouse.getModifiersEx();
+         if ((modifiers & MouseEvent.ALT_DOWN_MASK) != 0) {
+             eventAlt = true;
+         }
+         if ((modifiers & MouseEvent.CTRL_DOWN_MASK) != 0) {
+             eventCtrl = true;
+         }
+         if ((modifiers & MouseEvent.SHIFT_DOWN_MASK) != 0) {
+             eventShift = true;
+         }
          TMouseEvent mouseEvent = new TMouseEvent(TMouseEvent.Type.MOUSE_MOTION,
-             x, y, x, y, mouse1, mouse2, mouse3, false, false);
+             x, y, x, y, mouse1, mouse2, mouse3, false, false,
+             eventAlt, eventCtrl, eventShift);
  
          synchronized (eventQueue) {
              eventQueue.add(mouseEvent);
          boolean eventMouse1 = false;
          boolean eventMouse2 = false;
          boolean eventMouse3 = false;
+         boolean eventAlt = false;
+         boolean eventCtrl = false;
+         boolean eventShift = false;
          if ((modifiers & MouseEvent.BUTTON1_DOWN_MASK) != 0) {
              eventMouse1 = true;
          }
          if ((modifiers & MouseEvent.BUTTON3_DOWN_MASK) != 0) {
              eventMouse3 = true;
          }
+         if ((modifiers & MouseEvent.ALT_DOWN_MASK) != 0) {
+             eventAlt = true;
+         }
+         if ((modifiers & MouseEvent.CTRL_DOWN_MASK) != 0) {
+             eventCtrl = true;
+         }
+         if ((modifiers & MouseEvent.SHIFT_DOWN_MASK) != 0) {
+             eventShift = true;
+         }
          mouse1 = eventMouse1;
          mouse2 = eventMouse2;
          mouse3 = eventMouse3;
          int y = textRow(mouse.getY());
  
          TMouseEvent mouseEvent = new TMouseEvent(TMouseEvent.Type.MOUSE_DOWN,
-             x, y, x, y, mouse1, mouse2, mouse3, false, false);
+             x, y, x, y, mouse1, mouse2, mouse3, false, false,
+             eventAlt, eventCtrl, eventShift);
  
          synchronized (eventQueue) {
              eventQueue.add(mouseEvent);
          boolean eventMouse1 = false;
          boolean eventMouse2 = false;
          boolean eventMouse3 = false;
+         boolean eventAlt = false;
+         boolean eventCtrl = false;
+         boolean eventShift = false;
          if ((modifiers & MouseEvent.BUTTON1_DOWN_MASK) != 0) {
              eventMouse1 = true;
          }
          if ((modifiers & MouseEvent.BUTTON3_DOWN_MASK) != 0) {
              eventMouse3 = true;
          }
+         if ((modifiers & MouseEvent.ALT_DOWN_MASK) != 0) {
+             eventAlt = true;
+         }
+         if ((modifiers & MouseEvent.CTRL_DOWN_MASK) != 0) {
+             eventCtrl = true;
+         }
+         if ((modifiers & MouseEvent.SHIFT_DOWN_MASK) != 0) {
+             eventShift = true;
+         }
          if (mouse1) {
              mouse1 = false;
              eventMouse1 = true;
          int y = textRow(mouse.getY());
  
          TMouseEvent mouseEvent = new TMouseEvent(TMouseEvent.Type.MOUSE_UP,
-             x, y, x, y, eventMouse1, eventMouse2, eventMouse3, false, false);
+             x, y, x, y, eventMouse1, eventMouse2, eventMouse3, false, false,
+             eventAlt, eventCtrl, eventShift);
  
          synchronized (eventQueue) {
              eventQueue.add(mouseEvent);
          boolean eventMouse3 = false;
          boolean mouseWheelUp = false;
          boolean mouseWheelDown = false;
+         boolean eventAlt = false;
+         boolean eventCtrl = false;
+         boolean eventShift = false;
          if ((modifiers & MouseEvent.BUTTON1_DOWN_MASK) != 0) {
              eventMouse1 = true;
          }
          if ((modifiers & MouseEvent.BUTTON3_DOWN_MASK) != 0) {
              eventMouse3 = true;
          }
+         if ((modifiers & MouseEvent.ALT_DOWN_MASK) != 0) {
+             eventAlt = true;
+         }
+         if ((modifiers & MouseEvent.CTRL_DOWN_MASK) != 0) {
+             eventCtrl = true;
+         }
+         if ((modifiers & MouseEvent.SHIFT_DOWN_MASK) != 0) {
+             eventShift = true;
+         }
          mouse1 = eventMouse1;
          mouse2 = eventMouse2;
          mouse3 = eventMouse3;
          }
  
          TMouseEvent mouseEvent = new TMouseEvent(TMouseEvent.Type.MOUSE_DOWN,
-             x, y, x, y, mouse1, mouse2, mouse3, mouseWheelUp, mouseWheelDown);
+             x, y, x, y, mouse1, mouse2, mouse3, mouseWheelUp, mouseWheelDown,
+             eventAlt, eventCtrl, eventShift);
  
          synchronized (eventQueue) {
              eventQueue.add(mouseEvent);
diff --combined bits/Cell.java
index a8efa2b3c56465dc7ee93dc76fefa399bed85603,ed3c202a005a8c8a2a4187d08478cf8d62a6191c..ed3c202a005a8c8a2a4187d08478cf8d62a6191c
@@@ -419,7 -419,7 +419,7 @@@ public final class Cell extends CellAtt
          int B = 23;
          int hash = A;
          hash = (B * hash) + super.hashCode();
-         hash = (B * hash) + (int)ch;
+         hash = (B * hash) + ch;
          hash = (B * hash) + width.hashCode();
          if (image != null) {
              /*
diff --combined bits/CellAttributes.java
index 99366fda690740b738563493fef900a506436d7d,ad8619896295f00cec4fd0b46608e2082f2b2f53..ad8619896295f00cec4fd0b46608e2082f2b2f53
@@@ -62,7 -62,6 +62,6 @@@ public class CellAttributes 
       */
      private static final int PROTECT    = 0x10;
  
      // ------------------------------------------------------------------------
      // Variables --------------------------------------------------------------
      // ------------------------------------------------------------------------
diff --combined bits/ColorTheme.java
index ffba4d472cc67c36ebda4114e503ba1db545c719,3efce633243c9f82433d0212ad13e6caefe4922c..3efce633243c9f82433d0212ad13e6caefe4922c
@@@ -178,8 -178,11 +178,11 @@@ public class ColorTheme 
              return;
          }
  
-         while (token.equals("bold") || token.equals("blink")) {
-             if (token.equals("bold")) {
+         while (token.equals("bold")
+             || token.equals("bright")
+             || token.equals("blink")
+         ) {
+             if (token.equals("bold") || token.equals("bright")) {
                  bold = true;
                  token = tokenizer.nextToken();
              }
                  // Invalid line.
                  continue;
              }
-             String key = line.substring(0, line.indexOf(':')).trim();
-             String text = line.substring(line.indexOf(':') + 1);
+             String key = line.substring(0, line.indexOf('=')).trim();
+             String text = line.substring(line.indexOf('=') + 1);
              setColorFromString(key, text);
          }
          // All done.
          color.setBackColor(Color.BLUE);
          color.setBold(false);
          colors.put("teditor", color);
+         color = new CellAttributes();
+         color.setForeColor(Color.BLACK);
+         color.setBackColor(Color.CYAN);
+         color.setBold(false);
+         colors.put("teditor.selected", color);
  
          // TTable
          color = new CellAttributes();
          color.setBold(false);
          colors.put("tsplitpane", color);
  
+         // THelpWindow border - during window movement
+         color = new CellAttributes();
+         color.setForeColor(Color.GREEN);
+         color.setBackColor(Color.CYAN);
+         color.setBold(true);
+         colors.put("thelpwindow.windowmove", color);
+         // THelpWindow border
+         color = new CellAttributes();
+         color.setForeColor(Color.GREEN);
+         color.setBackColor(Color.CYAN);
+         color.setBold(true);
+         colors.put("thelpwindow.border", color);
+         // THelpWindow background
+         color = new CellAttributes();
+         color.setForeColor(Color.WHITE);
+         color.setBackColor(Color.CYAN);
+         color.setBold(true);
+         colors.put("thelpwindow.background", color);
+         // THelpWindow text
+         color = new CellAttributes();
+         color.setForeColor(Color.WHITE);
+         color.setBackColor(Color.BLUE);
+         color.setBold(false);
+         colors.put("thelpwindow.text", color);
+         // THelpWindow link
+         color = new CellAttributes();
+         color.setForeColor(Color.YELLOW);
+         color.setBackColor(Color.BLUE);
+         color.setBold(true);
+         colors.put("thelpwindow.link", color);
+         // THelpWindow link - active
+         color = new CellAttributes();
+         color.setForeColor(Color.YELLOW);
+         color.setBackColor(Color.CYAN);
+         color.setBold(true);
+         colors.put("thelpwindow.link.active", color);
      }
  
      /**
diff --combined bits/StringUtils.java
index fffce206875cf663480d2041aac121f88b58d01a,d33f71f4e0031710e52ed008fefa0f37f28883c4..d33f71f4e0031710e52ed008fefa0f37f28883c4
@@@ -30,6 -30,7 +30,7 @@@ package jexer.bits
  
  import java.util.List;
  import java.util.ArrayList;
+ import java.util.Arrays;
  
  /**
   * StringUtils contains methods to:
   *
   *    - Read/write a line of RFC4180 comma-separated values strings to/from a
   *      list of strings.
+  *
+  *    - Compute number of visible text cells for a given Unicode codepoint or
+  *      string.
+  *
+  *    - Convert bytes to and from base-64 encoding.
   */
  public class StringUtils {
  
       * @return the number of text cell columns required to display this string
       */
      public static int width(final String str) {
+         if (str == null) {
+             return 0;
+         }
          int n = 0;
          for (int i = 0; i < str.length();) {
              int ch = str.codePointAt(i);
          return ((ch >= 0x1f004) && (ch <= 0x1fffd));
      }
  
+     // ------------------------------------------------------------------------
+     // Base64 -----------------------------------------------------------------
+     // ------------------------------------------------------------------------
+     /*
+      * The Base64 encoder/decoder below is provided to support JDK 1.6 - JDK
+      * 11.  It was taken from https://sourceforge.net/projects/migbase64/
+      *
+      * The following changes were made:
+      *
+      * - Code has been indented and long lines cut to fit within 80 columns.
+      *
+      * - Char, String, and "fast" byte functions removed.  byte versions
+      *   retained and called toBase64()/fromBase64().
+      *
+      * - Enclosing braces added to blocks.
+      */
+     /**
+      * A very fast and memory efficient class to encode and decode to and
+      * from BASE64 in full accordance with RFC 2045.<br><br> On Windows XP
+      * sp1 with 1.4.2_04 and later ;), this encoder and decoder is about 10
+      * times faster on small arrays (10 - 1000 bytes) and 2-3 times as fast
+      * on larger arrays (10000 - 1000000 bytes) compared to
+      * <code>sun.misc.Encoder()/Decoder()</code>.<br><br>
+      *
+      * On byte arrays the encoder is about 20% faster than Jakarta Commons
+      * Base64 Codec for encode and about 50% faster for decoding large
+      * arrays. This implementation is about twice as fast on very small
+      * arrays (&lt 30 bytes). If source/destination is a <code>String</code>
+      * this version is about three times as fast due to the fact that the
+      * Commons Codec result has to be recoded to a <code>String</code> from
+      * <code>byte[]</code>, which is very expensive.<br><br>
+      *
+      * This encode/decode algorithm doesn't create any temporary arrays as
+      * many other codecs do, it only allocates the resulting array. This
+      * produces less garbage and it is possible to handle arrays twice as
+      * large as algorithms that create a temporary array. (E.g. Jakarta
+      * Commons Codec). It is unknown whether Sun's
+      * <code>sun.misc.Encoder()/Decoder()</code> produce temporary arrays but
+      * since performance is quite low it probably does.<br><br>
+      *
+      * The encoder produces the same output as the Sun one except that the
+      * Sun's encoder appends a trailing line separator if the last character
+      * isn't a pad. Unclear why but it only adds to the length and is
+      * probably a side effect. Both are in conformance with RFC 2045
+      * though.<br> Commons codec seem to always att a trailing line
+      * separator.<br><br>
+      *
+      * <b>Note!</b> The encode/decode method pairs (types) come in three
+      * versions with the <b>exact</b> same algorithm and thus a lot of code
+      * redundancy. This is to not create any temporary arrays for transcoding
+      * to/from different format types. The methods not used can simply be
+      * commented out.<br><br>
+      *
+      * There is also a "fast" version of all decode methods that works the
+      * same way as the normal ones, but har a few demands on the decoded
+      * input. Normally though, these fast verions should be used if the
+      * source if the input is known and it hasn't bee tampered with.<br><br>
+      *
+      * If you find the code useful or you find a bug, please send me a note
+      * at base64 @ miginfocom . com.
+      *
+      * Licence (BSD):
+      * ==============
+      *
+      * Copyright (c) 2004, Mikael Grev, MiG InfoCom AB. (base64 @ miginfocom
+      * . com) All rights reserved.
+      *
+      * Redistribution and use in source and binary forms, with or without
+      * modification, are permitted provided that the following conditions are
+      * met: Redistributions of source code must retain the above copyright
+      * notice, this list of conditions and the following disclaimer.
+      * Redistributions in binary form must reproduce the above copyright
+      * notice, this list of conditions and the following disclaimer in the
+      * documentation and/or other materials provided with the distribution.
+      * Neither the name of the MiG InfoCom AB nor the names of its
+      * contributors may be used to endorse or promote products derived from
+      * this software without specific prior written permission.
+      *
+      * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+      * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+      * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+      * A PARTICULAR PURPOSE ARE DISCLAIMED.  IN NO EVENT SHALL THE COPYRIGHT
+      * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+      * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+      * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+      * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+      * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+      * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+      * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+      *
+      * @version 2.2
+      * @author Mikael Grev
+      *         Date: 2004-aug-02
+      *         Time: 11:31:11
+      */
+     private static final char[] CA = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/".toCharArray();
+     private static final int[] IA = new int[256];
+     static {
+         Arrays.fill(IA, -1);
+         for (int i = 0, iS = CA.length; i < iS; i++) {
+             IA[CA[i]] = i;
+         }
+         IA['='] = 0;
+     }
+     /**
+      * Encodes a raw byte array into a BASE64 <code>byte[]</code>
+      * representation i accordance with RFC 2045.
+      * @param sArr The bytes to convert. If <code>null</code> or length 0
+      * an empty array will be returned.
+      * @return A BASE64 encoded array. Never <code>null</code>.
+      */
+     public final static String toBase64(byte[] sArr) {
+         // Check special case
+         int sLen = sArr != null ? sArr.length : 0;
+         if (sLen == 0) {
+             return "";
+         }
+         final boolean lineSep = true;
+         int eLen = (sLen / 3) * 3;                              // Length of even 24-bits.
+         int cCnt = ((sLen - 1) / 3 + 1) << 2;                   // Returned character count
+         int dLen = cCnt + (lineSep ? (cCnt - 1) / 76 << 1 : 0); // Length of returned array
+         byte[] dArr = new byte[dLen];
+         // Encode even 24-bits
+         for (int s = 0, d = 0, cc = 0; s < eLen;) {
+             // Copy next three bytes into lower 24 bits of int, paying
+             // attension to sign.
+             int i = (sArr[s++] & 0xff) << 16 | (sArr[s++] & 0xff) << 8 | (sArr[s++] & 0xff);
+             // Encode the int into four chars
+             dArr[d++] = (byte) CA[(i >>> 18) & 0x3f];
+             dArr[d++] = (byte) CA[(i >>> 12) & 0x3f];
+             dArr[d++] = (byte) CA[(i >>> 6) & 0x3f];
+             dArr[d++] = (byte) CA[i & 0x3f];
+             // Add optional line separator
+             if (lineSep && ++cc == 19 && d < dLen - 2) {
+                 dArr[d++] = '\r';
+                 dArr[d++] = '\n';
+                 cc = 0;
+             }
+         }
+         // Pad and encode last bits if source isn't an even 24 bits.
+         int left = sLen - eLen; // 0 - 2.
+         if (left > 0) {
+             // Prepare the int
+             int i = ((sArr[eLen] & 0xff) << 10) | (left == 2 ? ((sArr[sLen - 1] & 0xff) << 2) : 0);
+             // Set last four chars
+             dArr[dLen - 4] = (byte) CA[i >> 12];
+             dArr[dLen - 3] = (byte) CA[(i >>> 6) & 0x3f];
+             dArr[dLen - 2] = left == 2 ? (byte) CA[i & 0x3f] : (byte) '=';
+             dArr[dLen - 1] = '=';
+         }
+         try {
+             return new String(dArr, "UTF-8");
+         } catch (java.io.UnsupportedEncodingException e) {
+             throw new IllegalArgumentException(e);
+         }
+     }
+     /**
+      * Decodes a BASE64 encoded byte array. All illegal characters will
+      * be ignored and can handle both arrays with and without line
+      * separators.
+      * @param sArr The source array. Length 0 will return an empty
+      * array. <code>null</code> will throw an exception.
+      * @return The decoded array of bytes. May be of length 0. Will be
+      * <code>null</code> if the legal characters (including '=') isn't
+      * divideable by 4. (I.e. definitely corrupted).
+      */
+     public final static byte[] fromBase64(byte[] sArr) {
+         // Check special case
+         int sLen = sArr.length;
+         // Count illegal characters (including '\r', '\n') to know what
+         // size the returned array will be, so we don't have to
+         // reallocate & copy it later.
+         int sepCnt = 0; // Number of separator characters. (Actually illegal characters, but that's a bonus...)
+         for (int i = 0; i < sLen; i++) {
+             // If input is "pure" (I.e. no line separators or illegal chars)
+             // base64 this loop can be commented out.
+             if (IA[sArr[i] & 0xff] < 0) {
+                 sepCnt++;
+             }
+         }
+         // Check so that legal chars (including '=') are evenly
+         // divideable by 4 as specified in RFC 2045.
+         if ((sLen - sepCnt) % 4 != 0) {
+             return null;
+         }
+         int pad = 0;
+         for (int i = sLen; i > 1 && IA[sArr[--i] & 0xff] <= 0;) {
+             if (sArr[i] == '=') {
+                 pad++;
+             }
+         }
+         int len = ((sLen - sepCnt) * 6 >> 3) - pad;
+         byte[] dArr = new byte[len];       // Preallocate byte[] of exact length
+         for (int s = 0, d = 0; d < len;) {
+             // Assemble three bytes into an int from four "valid" characters.
+             int i = 0;
+             for (int j = 0; j < 4; j++) {   // j only increased if a valid char was found.
+                 int c = IA[sArr[s++] & 0xff];
+                 if (c >= 0) {
+                     i |= c << (18 - j * 6);
+                 } else {
+                     j--;
+                 }
+             }
+             // Add the bytes
+             dArr[d++] = (byte) (i >> 16);
+             if (d < len) {
+                 dArr[d++]= (byte) (i >> 8);
+                 if (d < len) {
+                     dArr[d++] = (byte) i;
+                 }
+             }
+         }
+         return dArr;
+     }
  }
diff --combined demos/Demo6.java
index db0b5c9d3f53a7083d4a47a99930ebcec9058d3b,41d1f2c3f323b84a4feaa583534a34c793f06344..41d1f2c3f323b84a4feaa583534a34c793f06344
@@@ -111,7 -111,7 +111,7 @@@ public class Demo6 
               * Make a new Swing window for the second application.
               */
              SwingBackend monitorBackend = new SwingBackend(width + 5,
-                 height + 5, 16);
+                 height + 5, 20);
  
              /*
               * Setup the second application, give it the basic file and
index fda7bd7a15ae6567eee7a21d17b090cc46648c4f,faf3530cd47e86da9fac5103aac5e7dd6303dcba..faf3530cd47e86da9fac5103aac5e7dd6303dcba
@@@ -103,8 -103,9 +103,9 @@@ public class DemoCheckBoxWindow extend
          TRadioGroup group = addRadioGroup(1, row,
              i18n.getString("radioGroupTitle"));
          group.addRadioButton(i18n.getString("radioOption1"));
-         group.addRadioButton(i18n.getString("radioOption2"));
+         group.addRadioButton(i18n.getString("radioOption2"), true);
          group.addRadioButton(i18n.getString("radioOption3"));
+         group.setRequiresSelection(true);
  
          List<String> comboValues = new ArrayList<String>();
          comboValues.add(i18n.getString("comboBoxString0"));
diff --combined event/TMouseEvent.java
index 496d8bc06422baa3014a95a98ae953f60cd4325e,e52989814005bec2d2d303372519276ed4e4410f..e52989814005bec2d2d303372519276ed4e4410f
@@@ -118,6 -118,21 +118,21 @@@ public class TMouseEvent extends TInput
       */
      private boolean mouseWheelDown;
  
+     /**
+      * Keyboard modifier ALT.
+      */
+     private boolean alt;
+     /**
+      * Keyboard modifier CTRL.
+      */
+     private boolean ctrl;
+     /**
+      * Keyboard modifier SHIFT.
+      */
+     private boolean shift;
      // ------------------------------------------------------------------------
      // Constructors -----------------------------------------------------------
      // ------------------------------------------------------------------------
       * @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
+      * @param alt if true, ALT was pressed with this mouse event
+      * @param ctrl if true, CTRL was pressed with this mouse event
+      * @param shift if true, SHIFT was pressed with this mouse event
       */
      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) {
+         final boolean mouseWheelUp, final boolean mouseWheelDown,
+         final boolean alt, final boolean ctrl, final boolean shift) {
  
          this.type               = type;
          this.x                  = x;
          this.mouse3             = mouse3;
          this.mouseWheelUp       = mouseWheelUp;
          this.mouseWheelDown     = mouseWheelDown;
+         this.alt                = alt;
+         this.ctrl               = ctrl;
+         this.shift              = shift;
      }
  
      // ------------------------------------------------------------------------
          return mouseWheelDown;
      }
  
+     /**
+      * 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;
+     }
      /**
       * Create a duplicate instance.
       *
       */
      public TMouseEvent dup() {
          TMouseEvent mouse = new TMouseEvent(type, x, y, absoluteX, absoluteY,
-             mouse1, mouse2, mouse3, mouseWheelUp, mouseWheelDown);
+             mouse1, mouse2, mouse3, mouseWheelUp, mouseWheelDown,
+             alt, ctrl, shift);
          return mouse;
      }
  
       */
      @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",
+         return String.format("Mouse: %s x %d y %d absoluteX %d absoluteY %d 1 %s 2 %s 3 %s DOWN %s UP %s ALT %s CTRL %s SHIFT %s",
              type,
              x, y,
              absoluteX, absoluteY,
              mouse2,
              mouse3,
              mouseWheelUp,
-             mouseWheelDown);
+             mouseWheelDown,
+             alt, ctrl, shift);
      }
  
  }
index 3d8cdb0312494293b5e90bdeee39c5f21b32f659,70faff4eabaa0f932352b1886ea5651bfc424ac9..70faff4eabaa0f932352b1886ea5651bfc424ac9
@@@ -244,7 -244,7 +244,7 @@@ public class TimeoutInputStream extend
  
          if (timeoutMillis == 0) {
              // Block on the read().
-             return stream.read(b);
+             return stream.read(b, off, len);
          }
  
          int remaining = len;
index ee2bf5aba5e5f70d4da15e6376bcd36164b26f7e,4bcb0cffc4c29e3a6cb088efa7a8afa8f5c4bebd..4bcb0cffc4c29e3a6cb088efa7a8afa8f5c4bebd
@@@ -146,11 -146,11 +146,11 @@@ public class StretchLayoutManager imple
       */
      private void layoutChildren() {
          double widthRatio = (double) width / originalWidth;
-         if (!Double.isFinite(widthRatio)) {
+         if (Math.abs(widthRatio) > Double.MAX_VALUE) {
              widthRatio = 1;
          }
          double heightRatio = (double) height / originalHeight;
-         if (!Double.isFinite(heightRatio)) {
+         if (Math.abs(heightRatio) > Double.MAX_VALUE) {
              heightRatio = 1;
          }
          for (TWidget child: children.keySet()) {
diff --combined menu/TMenu.java
index 6d746df0c42ebc3f6da6ede018a33ff439233235,6a875c7c8377f154e9cafb07b064cde7ab889d3d..6a875c7c8377f154e9cafb07b064cde7ab889d3d
@@@ -72,10 -72,12 +72,12 @@@ public class TMenu extends TWindow 
      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;
+     public static final int MID_UNDO            = 20;
+     public static final int MID_REDO            = 21;
+     public static final int MID_CUT             = 22;
+     public static final int MID_COPY            = 23;
+     public static final int MID_PASTE           = 24;
+     public static final int MID_CLEAR           = 25;
  
      // Search menu
      public static final int MID_FIND            = 30;
       */
      private MnemonicString mnemonic;
  
+     /**
+      * If true, draw icons with menu items.  Note package private access.
+      */
+     boolean useIcons = false;
      // ------------------------------------------------------------------------
      // Constructors -----------------------------------------------------------
      // ------------------------------------------------------------------------
          setHeight(2);
  
          setActive(false);
+         if (System.getProperty("jexer.menuIcons", "false").equals("true")) {
+             useIcons = true;
+         }
      }
  
      // ------------------------------------------------------------------------
          final boolean enabled) {
  
          assert (id >= 1024);
-         return addItemInternal(id, label, null, enabled);
+         return addItemInternal(id, label, null, enabled, -1);
      }
  
      /**
      private TMenuItem addItemInternal(final int id, final String label,
          final TKeypress key) {
  
-         return addItemInternal(id, label, key, true);
+         return addItemInternal(id, label, key, true, -1);
      }
  
      /**
       * @param label menu item label
       * @param key global keyboard accelerator
       * @param enabled default state for enabled
+      * @param icon icon picture/emoji
       * @return the new menu item
       */
      private TMenuItem addItemInternal(final int id, final String label,
-         final TKeypress key, final boolean enabled) {
+         final TKeypress key, final boolean enabled, final int icon) {
  
          int newY = getChildren().size() + 1;
          assert (newY < getHeight());
  
-         TMenuItem menuItem = new TMenuItem(this, id, 1, newY, label);
+         TMenuItem menuItem = new TMenuItem(this, id, 1, newY, label, icon);
          menuItem.setKey(key);
          menuItem.setEnabled(enabled);
          setHeight(getHeight() + 1);
  
          String label;
          TKeypress key = null;
+         int icon = -1;
          boolean checkable = false;
          boolean checked = false;
  
  
          case MID_REPAINT:
              label = i18n.getString("menuRepaintDesktop");
+             icon = 0x1F3A8;
              break;
  
          case MID_VIEW_IMAGE:
  
          case MID_NEW:
              label = i18n.getString("menuNew");
+             icon = 0x1F5CE;
              break;
  
          case MID_EXIT:
              label = i18n.getString("menuExit");
              key = kbAltX;
+             icon = 0x1F5D9;
              break;
  
          case MID_SHELL:
              label = i18n.getString("menuShell");
+             icon = 0x1F5AE;
              break;
  
          case MID_OPEN_FILE:
              label = i18n.getString("menuOpen");
              key = kbF3;
+             icon = 0x1F5C1;
              break;
  
+         case MID_UNDO:
+             label = i18n.getString("menuUndo");
+             key = kbCtrlZ;
+             break;
+         case MID_REDO:
+             label = i18n.getString("menuRedo");
+             key = kbCtrlY;
+             break;
          case MID_CUT:
              label = i18n.getString("menuCut");
              key = kbCtrlX;
+             icon = 0x1F5F6;
              break;
          case MID_COPY:
              label = i18n.getString("menuCopy");
              key = kbCtrlC;
+             icon = 0x1F5D0;
              break;
          case MID_PASTE:
              label = i18n.getString("menuPaste");
              key = kbCtrlV;
+             icon = 0x1F4CB;
              break;
          case MID_CLEAR:
              label = i18n.getString("menuClear");
-             // key = kbDel;
              break;
  
          case MID_FIND:
              label = i18n.getString("menuFind");
+             icon = 0x1F50D;
              break;
          case MID_REPLACE:
              label = i18n.getString("menuReplace");
              break;
          case MID_CASCADE:
              label = i18n.getString("menuWindowCascade");
+             icon = 0x1F5D7;
              break;
          case MID_CLOSE_ALL:
              label = i18n.getString("menuWindowCloseAll");
          case MID_WINDOW_MOVE:
              label = i18n.getString("menuWindowMove");
              key = kbCtrlF5;
+             icon = 0x263C;
              break;
          case MID_WINDOW_ZOOM:
              label = i18n.getString("menuWindowZoom");
              key = kbF5;
+             icon = 0x2195;
              break;
          case MID_WINDOW_NEXT:
              label = i18n.getString("menuWindowNext");
              key = kbF6;
+             icon = 0x2192;
              break;
          case MID_WINDOW_PREVIOUS:
              label = i18n.getString("menuWindowPrevious");
              key = kbShiftF6;
+             icon = 0x2190;
              break;
          case MID_WINDOW_CLOSE:
              label = i18n.getString("menuWindowClose");
              throw new IllegalArgumentException("Invalid menu ID: " + id);
          }
  
-         TMenuItem item = addItemInternal(id, label, key, enabled);
+         TMenuItem item = addItemInternal(id, label, key, enabled, icon);
          item.setCheckable(checkable);
          return item;
      }
diff --combined menu/TMenu.properties
index 4a0f8e6f6fef8b301b7467be871263b5858639cc,692293eb57f98bd7da88c66488c2b611c2fe69cc..692293eb57f98bd7da88c66488c2b611c2fe69cc
@@@ -2,6 -2,8 +2,8 @@@ menuNew=&Ne
  menuExit=E&xit
  menuShell=O&S Shell
  menuOpen=&Open
+ menuUndo=&Undo
+ menuRedo=&Redo
  menuCut=Cu&t
  menuCopy=&Copy
  menuPaste=&Paste
diff --combined menu/TMenuItem.java
index d9dfc2ac5482b64123713f45e8b58f7f9abf3e3d,b478059c077d17916dffff4b6b864f22874cbaf6..b478059c077d17916dffff4b6b864f22874cbaf6
@@@ -80,6 -80,11 +80,11 @@@ public class TMenuItem extends TWidget 
       */
      private MnemonicString mnemonic;
  
+     /**
+      * An optional 2-cell-wide picture/icon for this item.
+      */
+     private int icon = -1;
      // ------------------------------------------------------------------------
      // Constructors -----------------------------------------------------------
      // ------------------------------------------------------------------------
      TMenuItem(final TMenu parent, final int id, final int x, final int y,
          final String label) {
  
+         this(parent, id, x, y, label, -1);
+     }
+     /**
+      * 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
+      * @param icon icon picture/emoji
+      */
+     TMenuItem(final TMenu parent, final int id, final int x, final int y,
+         final String label, final int icon) {
          // Set parent and window
          super(parent);
  
          setY(y);
          setHeight(1);
          this.label = mnemonic.getRawLabel();
-         setWidth(StringUtils.width(label) + 4);
+         if (parent.useIcons) {
+             setWidth(StringUtils.width(label) + 6);
+         } else {
+             setWidth(StringUtils.width(label) + 4);
+         }
          this.id = id;
+         this.icon = icon;
  
          // Default state for some known menu items
          switch (id) {
              }
          }
  
+         boolean useIcons = ((TMenu) getParent()).useIcons;
          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);
+         putStringXY(2 + (useIcons ? 2 : 0), 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);
+             putCharXY(2 + (useIcons ? 2 : 0) + mnemonic.getScreenShortcutIdx(),
+                 0, mnemonic.getShortcut(), menuMnemonicColor);
          }
          if (checked) {
              assert (checkable);
              putCharXY(1, 0, GraphicsChars.CHECK, menuColor);
          }
+         if ((useIcons == true) && (icon != -1)) {
+             putCharXY(2, 0, icon, menuColor);
+         }
      }
  
      // ------------------------------------------------------------------------
          if (key != null) {
              int newWidth = (StringUtils.width(label) + 4 +
                  StringUtils.width(key.toString()) + 2);
+             if (((TMenu) getParent()).useIcons) {
+                 newWidth += 2;
+             }
              if (newWidth > getWidth()) {
                  setWidth(newWidth);
              }
          }
      }
  
+     /**
+      * Get a picture/emoji icon for this menu item.
+      *
+      * @return the codepoint, or -1 if no icon is specified for this menu
+      * item
+      */
+     public final int getIcon() {
+         return icon;
+     }
+     /**
+      * Set a picture/emoji icon for this menu item.
+      *
+      * @param icon a codepoint, or -1 to unset the icon
+      */
+     public final void setIcon(final int icon) {
+         this.icon = icon;
+     }
      /**
       * Dispatch event(s) due to selection or click.
       */
diff --combined menu/TSubMenu.java
index e285c5ab6d78c485f67ed6e2597c4c17e31af189,be281b52d97d58741072632e9009e5dc1c344904..be281b52d97d58741072632e9009e5dc1c344904
@@@ -212,6 -212,21 +212,21 @@@ public class TSubMenu extends TMenuIte
          return menu.addItem(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) {
+         return menu.addItem(id, label, key, enabled);
+     }
      /**
       * Convenience function to add a menu item.
       *
          return menu.addItem(id, label);
      }
  
+     /**
+      * 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) {
+         return menu.addItem(id, label, enabled);
+     }
      /**
       * Convenience function to add one of the default menu items.
       *
diff --combined teditor/Document.java
index 2abfef6635f3c1877fc733ee36ea8c67d01160b6,b4a9a3bfb6b3ab476d29b45b3a01b4fbdca77816..b4a9a3bfb6b3ab476d29b45b3a01b4fbdca77816
@@@ -76,6 -76,23 +76,23 @@@ public class Document 
       */
      private Highlighter highlighter = new Highlighter();
  
+     /**
+      * The tab stop size.
+      */
+     private int tabSize = 8;
+     /**
+      * If true, backspace at an indent level goes back a full indent level.
+      * If false, backspace always goes back one column.
+      */
+     private boolean backspaceUnindents = false;
+     /**
+      * If true, save files with tab characters.  If false, convert tabs to
+      * spaces when saving files.
+      */
+     private boolean saveWithTabs = false;
      // ------------------------------------------------------------------------
      // Constructors -----------------------------------------------------------
      // ------------------------------------------------------------------------
      public Document(final String str, final CellAttributes defaultColor) {
          this.defaultColor = defaultColor;
  
-         // TODO: set different colors based on file extension
+         // Set colors to resemble the Borland IDE colors, but for Java
+         // language keywords.
          highlighter.setJavaColors();
  
          String [] rawLines = str.split("\n");
          }
      }
  
+     /**
+      * Private constructor used by dup().
+      */
+     private Document() {
+         // NOP
+     }
      // ------------------------------------------------------------------------
      // Document ---------------------------------------------------------------
      // ------------------------------------------------------------------------
  
+     /**
+      * Create a duplicate instance.
+      *
+      * @return duplicate intance
+      */
+     public Document dup() {
+         Document other = new Document();
+         for (Line line: lines) {
+             other.lines.add(line.dup());
+         }
+         other.lineNumber = lineNumber;
+         other.overwrite = overwrite;
+         other.dirty = dirty;
+         other.defaultColor = defaultColor;
+         other.highlighter.setTo(highlighter);
+         return other;
+     }
      /**
       * Get the overwrite flag.
       *
       * @return true if addChar() overwrites data, false if it inserts
       */
-     public boolean getOverwrite() {
+     public boolean isOverwrite() {
          return overwrite;
      }
  
          return dirty;
      }
  
+     /**
+      * Unset the dirty flag.
+      */
+     public void setNotDirty() {
+         dirty = false;
+     }
      /**
       * Save contents to file.
       *
                  "UTF-8");
  
              for (Line line: lines) {
-                 output.write(line.getRawString());
+                 if (saveWithTabs) {
+                     output.write(convertSpacesToTabs(line.getRawString()));
+                 } else {
+                     output.write(line.getRawString());
+                 }
                  output.write("\n");
              }
  
          // If at the beginning of a word already, push past it.
          if ((getChar() != -1)
              && (getRawLine().length() > 0)
-             && !Character.isSpace((char) getChar())
+             && !Character.isWhitespace((char) getChar())
          ) {
              left();
          }
          // int line = lineNumber;
          while ((getChar() == -1)
              || (getRawLine().length() == 0)
-             || Character.isSpace((char) getChar())
+             || Character.isWhitespace((char) getChar())
          ) {
              if (left() == false) {
                  return;
  
          assert (getChar() != -1);
  
-         if (!Character.isSpace((char) getChar())
+         if (!Character.isWhitespace((char) getChar())
              && (getRawLine().length() > 0)
          ) {
              // Advance until at the beginning of the document or a whitespace
              // is encountered.
-             while (!Character.isSpace((char) getChar())) {
+             while (!Character.isWhitespace((char) getChar())) {
                  int line = lineNumber;
                  if (left() == false) {
                      // End of document, bail out.
              }
              if (lineNumber != line) {
                  // We wrapped a line.  Here that counts as whitespace.
-                 if (!Character.isSpace((char) getChar())) {
+                 if (!Character.isWhitespace((char) getChar())) {
                      // We found a character immediately after the line.
                      // Done!
                      return;
          }
          assert (getChar() != -1);
  
-         if (!Character.isSpace((char) getChar())
+         if (!Character.isWhitespace((char) getChar())
              && (getRawLine().length() > 0)
          ) {
              // Advance until at the end of the document or a whitespace is
              // encountered.
-             while (!Character.isSpace((char) getChar())) {
+             while (!Character.isWhitespace((char) getChar())) {
                  line = lineNumber;
                  if (right() == false) {
                      // End of document, bail out.
                  }
                  if (lineNumber != line) {
                      // We wrapped a line.  Here that counts as whitespace.
-                     if (!Character.isSpace((char) getChar())
+                     if (!Character.isWhitespace((char) getChar())
                          && (getRawLine().length() > 0)
                      ) {
                          // We found a character immediately after the line.
              }
              if (lineNumber != line) {
                  // We wrapped a line.  Here that counts as whitespace.
-                 if (!Character.isSpace((char) getChar())) {
+                 if (!Character.isWhitespace((char) getChar())) {
                      // We found a character immediately after the line.
                      // Done!
                      return;
          }
          assert (getChar() != -1);
  
-         if (Character.isSpace((char) getChar())) {
+         if (Character.isWhitespace((char) getChar())) {
              // Advance until at the end of the document or a non-whitespace
              // is encountered.
-             while (Character.isSpace((char) getChar())) {
+             while (Character.isWhitespace((char) getChar())) {
                  if (right() == false) {
                      // End of document, bail out.
                      return;
          dirty = true;
          int cursor = lines.get(lineNumber).getCursor();
          if (cursor > 0) {
-             lines.get(lineNumber).backspace();
+             lines.get(lineNumber).backspace(tabSize, backspaceUnindents);
          } else if (lineNumber > 0) {
              // Join two lines
              lineNumber--;
          }
      }
  
+     /**
+      * Get the tab stop size.
+      *
+      * @return the tab stop size
+      */
+     public int getTabSize() {
+         return tabSize;
+     }
+     /**
+      * Set the tab stop size.
+      *
+      * @param tabSize the new tab stop size
+      */
+     public void setTabSize(final int tabSize) {
+         this.tabSize = tabSize;
+     }
+     /**
+      * Set the backspace unindent option.
+      *
+      * @param backspaceUnindents If true, backspace at an indent level goes
+      * back a full indent level.  If false, backspace always goes back one
+      * column.
+      */
+     public void setBackspaceUnindents(final boolean backspaceUnindents) {
+         this.backspaceUnindents = backspaceUnindents;
+     }
+     /**
+      * Set the save with tabs option.
+      *
+      * @param saveWithTabs If true, save files with tab characters.  If
+      * false, convert tabs to spaces when saving files.
+      */
+     public void setSaveWithTabs(final boolean saveWithTabs) {
+         this.saveWithTabs = saveWithTabs;
+     }
+     /**
+      * Handle the tab character.
+      */
+     public void tab() {
+         if (overwrite) {
+             del();
+         }
+         lines.get(lineNumber).tab(tabSize);
+     }
+     /**
+      * Handle the backtab (shift-tab) character.
+      */
+     public void backTab() {
+         lines.get(lineNumber).backTab(tabSize);
+     }
      /**
       * Get a (shallow) copy of the list of lines.
       *
          return lines.get(lineNumber).getDisplayLength();
      }
  
+     /**
+      * Get the entire contents of the document as one string.
+      *
+      * @return the document contents
+      */
+     public String getText() {
+         StringBuilder sb = new StringBuilder();
+         for (Line line: getLines()) {
+             sb.append(line.getRawString());
+             sb.append("\n");
+         }
+         return sb.toString();
+     }
+     /**
+      * Trim trailing whitespace from lines and trailing empty
+      * lines from the document.
+      */
+     public void cleanWhitespace() {
+         for (Line line: getLines()) {
+             line.trimRight();
+         }
+         if (lines.size() == 0) {
+             return;
+         }
+         while (lines.get(lines.size() - 1).length() == 0) {
+             lines.remove(lines.size() - 1);
+         }
+         if (lineNumber > lines.size() - 1) {
+             lineNumber = lines.size() - 1;
+         }
+     }
+     /**
+      * Set keyword highlighting.
+      *
+      * @param enabled if true, enable keyword highlighting
+      */
+     public void setHighlighting(final boolean enabled) {
+         highlighter.setEnabled(enabled);
+         for (Line line: getLines()) {
+             line.scanLine();
+         }
+     }
+     /**
+      * Convert a string with leading spaces to a mix of tabs and spaces.
+      *
+      * @param string the string to convert
+      */
+     private String convertSpacesToTabs(final String string) {
+         if (string.length() == 0) {
+             return string;
+         }
+         int start = 0;
+         while (string.charAt(start) == ' ') {
+             start++;
+         }
+         int tabCount = start / 8;
+         if (tabCount == 0) {
+             return string;
+         }
+         StringBuilder sb = new StringBuilder(string.length());
+         for (int i = 0; i < tabCount; i++) {
+             sb.append('\t');
+         }
+         sb.append(string.substring(tabCount * 8));
+         return sb.toString();
+     }
  }
diff --combined teditor/Highlighter.java
index a48419455e541697e12da7dfd81ebb8db52d7e9e,23ee90014e863bc0568a3526f4e086a793b6465c..23ee90014e863bc0568a3526f4e086a793b6465c
@@@ -56,13 -56,36 +56,36 @@@ public class Highlighter 
       * Public constructor sets the theme to the default.
       */
      public Highlighter() {
-         colors = new TreeMap<String, CellAttributes>();
+         // NOP
      }
  
      // ------------------------------------------------------------------------
      // Highlighter ------------------------------------------------------------
      // ------------------------------------------------------------------------
  
+     /**
+      * Set keyword highlighting.
+      *
+      * @param enabled if true, enable keyword highlighting
+      */
+     public void setEnabled(final boolean enabled) {
+         if (enabled) {
+             setJavaColors();
+         } else {
+             colors = null;
+         }
+     }
+     /**
+      * Set my field values to that's field.
+      *
+      * @param rhs an instance of Highlighter
+      */
+     public void setTo(final Highlighter rhs) {
+         colors = new TreeMap<String, CellAttributes>();
+         colors.putAll(rhs.colors);
+     }
      /**
       * See if this is a character that should split a word.
       *
       * @return color associated with name, e.g. bold yellow on blue
       */
      public CellAttributes getColor(final String name) {
-         CellAttributes attr = (CellAttributes) colors.get(name);
+         if (colors == null) {
+             return null;
+         }
+         CellAttributes attr = colors.get(name);
          return attr;
      }
  
       * Sets to defaults that resemble the Borland IDE colors.
       */
      public void setJavaColors() {
+         colors = new TreeMap<String, CellAttributes>();
          CellAttributes color;
  
-         String [] keywords = {
+         String [] types = {
              "boolean", "byte", "short", "int", "long", "char", "float",
-             "double", "void", "new",
-             "static", "final", "volatile", "synchronized", "abstract",
-             "public", "private", "protected",
-             "class", "interface", "extends", "implements",
+             "double", "void",
+         };
+         color = new CellAttributes();
+         color.setForeColor(Color.GREEN);
+         color.setBackColor(Color.BLUE);
+         color.setBold(true);
+         for (String str: types) {
+             colors.put(str, color);
+         }
+         String [] modifiers = {
+             "abstract", "final", "native", "private", "protected", "public",
+             "static", "strictfp", "synchronized", "transient", "volatile",
+         };
+         color = new CellAttributes();
+         color.setForeColor(Color.WHITE);
+         color.setBackColor(Color.BLUE);
+         color.setBold(true);
+         for (String str: modifiers) {
+             colors.put(str, color);
+         }
+         String [] keywords = {
+             "new", "class", "interface", "extends", "implements",
              "if", "else", "do", "while", "for", "break", "continue",
              "switch", "case", "default",
          };
          color = new CellAttributes();
-         color.setForeColor(Color.WHITE);
+         color.setForeColor(Color.YELLOW);
          color.setBackColor(Color.BLUE);
          color.setBold(true);
          for (String str: keywords) {
diff --combined teditor/Line.java
index 7cd5febabee8462f6c51bc66e886903795aff698,b5c980a59f9c9812b6f6a84ce832fe9781051702..b5c980a59f9c9812b6f6a84ce832fe9781051702
@@@ -32,6 -32,7 +32,7 @@@ import java.util.ArrayList
  import java.util.List;
  
  import jexer.bits.CellAttributes;
+ import jexer.bits.GraphicsChars;
  import jexer.bits.StringUtils;
  
  /**
@@@ -92,7 -93,31 +93,31 @@@ public class Line 
  
          this.defaultColor = defaultColor;
          this.highlighter = highlighter;
-         this.rawText = new StringBuilder(str);
+         this.rawText = new StringBuilder();
+         int col = 0;
+         for (int i = 0; i < str.length(); i++) {
+             char ch = str.charAt(i);
+             if (ch == '\t') {
+                 // Expand tabs
+                 int j = col % 8;
+                 do {
+                     rawText.append(' ');
+                     j++;
+                     col++;
+                 } while ((j % 8) != 0);
+                 continue;
+             }
+             if ((ch <= 0x20) || (ch == 0x7F)) {
+                 // Replace all other C0 bytes with CP437 glyphs.
+                 rawText.append(GraphicsChars.CP437[(int) ch]);
+                 col++;
+                 continue;
+             }
+             rawText.append(ch);
+             col++;
+         }
  
          scanLine();
      }
          this(str, defaultColor, null);
      }
  
+     /**
+      * Private constructor used by dup().
+      */
+     private Line() {
+         // NOP
+     }
      // ------------------------------------------------------------------------
      // Line -------------------------------------------------------------------
      // ------------------------------------------------------------------------
  
+     /**
+      * Create a duplicate instance.
+      *
+      * @return duplicate intance
+      */
+     public Line dup() {
+         Line other = new Line();
+         other.defaultColor = defaultColor;
+         other.highlighter = highlighter;
+         other.position = position;
+         other.screenPosition = screenPosition;
+         other.rawText = new StringBuilder(rawText);
+         other.scanLine();
+         return other;
+     }
      /**
       * Get a (shallow) copy of the words in this line.
       *
      }
  
      /**
-      * Scan rawText and make words out of it.
+      * Get the raw length of this line.
+      *
+      * @return the length of this line in characters, which may be different
+      * from the number of cells needed to display it
+      */
+     public int length() {
+         return rawText.length();
+     }
+     /**
+      * Scan rawText and make words out of it.  Note package private access.
       */
-     private void scanLine() {
+     void scanLine() {
          words.clear();
          Word word = new Word(this.defaultColor, this.highlighter);
          words.add(word);
          if (getDisplayLength() == 0) {
              return false;
          }
-         if (position == getDisplayLength() - 1) {
+         if (screenPosition == getDisplayLength() - 1) {
              return false;
          }
          if (position < rawText.length()) {
       * @return true if the cursor position changed
       */
      public boolean end() {
-         if (position != getDisplayLength() - 1) {
+         if (screenPosition != getDisplayLength() - 1) {
              position = rawText.length();
              screenPosition = StringUtils.width(rawText.toString());
              return true;
      public void del() {
          assert (words.size() > 0);
  
-         if (position < getDisplayLength()) {
+         if (screenPosition < getDisplayLength()) {
              int n = Character.charCount(rawText.codePointAt(position));
              for (int i = 0; i < n; i++) {
                  rawText.deleteCharAt(position);
  
      /**
       * Delete the character immediately preceeding the cursor.
+      *
+      * @param tabSize the tab stop size
+      * @param backspaceUnindents If true, backspace at an indent level goes
+      * back a full indent level.  If false, backspace always goes back one
+      * column.
       */
-     public void backspace() {
+     public void backspace(final int tabSize, final boolean backspaceUnindents) {
+         if ((backspaceUnindents == true)
+             && (tabSize > 0)
+             && (screenPosition > 0)
+             && (rawText.charAt(position - 1) == ' ')
+             && ((screenPosition % tabSize) == 0)
+         ) {
+             boolean doBackTab = true;
+             for (int i = 0; i < position; i++) {
+                 if (rawText.charAt(i) != ' ') {
+                     doBackTab = false;
+                     break;
+                 }
+             }
+             if (doBackTab) {
+                 backTab(tabSize);
+                 return;
+             }
+         }
          if (left()) {
              del();
          }
       * @param ch the character to insert
       */
      public void addChar(final int ch) {
-         if (position < getDisplayLength() - 1) {
+         if (screenPosition < getDisplayLength() - 1) {
              rawText.insert(position, Character.toChars(ch));
          } else {
              rawText.append(Character.toChars(ch));
       * @param ch the character to replace
       */
      public void replaceChar(final int ch) {
-         if (position < getDisplayLength() - 1) {
+         if (screenPosition < getDisplayLength() - 1) {
              // Replace character
              String oldText = rawText.toString();
              rawText = new StringBuilder(oldText.substring(0, position));
       * @param screenPosition the position on screen
       * @return the equivalent position in text
       */
-     protected int screenToTextPosition(final int screenPosition) {
+     private int screenToTextPosition(final int screenPosition) {
          if (screenPosition == 0) {
              return 0;
          }
              " exceeds available text length " + rawText.length());
      }
  
+     /**
+      * Trim trailing whitespace from line, repositioning cursor if needed.
+      */
+     public void trimRight() {
+         if (rawText.length() == 0) {
+             return;
+         }
+         if (!Character.isWhitespace(rawText.charAt(rawText.length() - 1))) {
+             return;
+         }
+         while ((rawText.length() > 0)
+             && Character.isWhitespace(rawText.charAt(rawText.length() - 1))
+         ) {
+             rawText.deleteCharAt(rawText.length() - 1);
+         }
+         if (position >= rawText.length()) {
+             end();
+         }
+         scanLine();
+     }
+     /**
+      * Handle the tab character.
+      *
+      * @param tabSize the tab stop size
+      */
+     public void tab(final int tabSize) {
+         if (tabSize > 0) {
+             do {
+                 addChar(' ');
+             } while ((screenPosition % tabSize) != 0);
+         }
+     }
+     /**
+      * Handle the backtab (shift-tab) character.
+      *
+      * @param tabSize the tab stop size
+      */
+     public void backTab(final int tabSize) {
+         if ((tabSize > 0) && (screenPosition > 0)
+             && (rawText.charAt(position - 1) == ' ')
+         ) {
+             do {
+                 backspace(tabSize, false);
+             } while (((screenPosition % tabSize) != 0)
+                 && (screenPosition > 0)
+                 && (rawText.charAt(position - 1) == ' '));
+         }
+     }
  }
diff --combined teditor/Word.java
index eada29cff83ed8b1c61c59d1741b5646d1f645b8,483f9c3d86c46a1dfbf225876eb7b42c219ba0c1..483f9c3d86c46a1dfbf225876eb7b42c219ba0c1
@@@ -135,11 -135,6 +135,6 @@@ public class Word 
       * @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());
      }
  
index 06a05a330ddccb50cc3ecf142cea0c4c87c38df9,87e6952fb515447b43500395d40cb8e80d0c919b..87e6952fb515447b43500395d40cb8e80d0c919b
@@@ -248,4 -248,29 +248,29 @@@ public class DisplayLine 
          chars[chars.length - 1] = new Cell(newCell);
      }
  
+     /**
+      * Determine if line contains image data.
+      *
+      * @return true if the line has image data
+      */
+     public boolean isImage() {
+         for (int i = 0; i < chars.length; i++) {
+             if (chars[i].isImage()) {
+                 return true;
+             }
+         }
+         return false;
+     }
+     /**
+      * Clear image data from line.
+      */
+     public void clearImages() {
+         for (int i = 0; i < chars.length; i++) {
+             if (chars[i].isImage()) {
+                 chars[i].reset();
+             }
+         }
+     }
  }
diff --combined tterminal/ECMA48.java
index 1d3481169cc5300c5134c652e7745a962f42a2ec,537b2e0a4a3ba25238ee5d935f393aa07fcba24c..537b2e0a4a3ba25238ee5d935f393aa07fcba24c
   */
  package jexer.tterminal;
  
- import java.awt.Graphics2D;
+ import java.awt.Graphics;
  import java.awt.image.BufferedImage;
+ import java.io.BufferedInputStream;
  import java.io.BufferedOutputStream;
+ import java.io.ByteArrayInputStream;
  import java.io.CharArrayWriter;
  import java.io.InputStream;
  import java.io.InputStreamReader;
@@@ -45,6 -47,7 +47,7 @@@ import java.util.ArrayList
  import java.util.Collections;
  import java.util.HashMap;
  import java.util.List;
+ import javax.imageio.ImageIO;
  
  import jexer.TKeypress;
  import jexer.backend.GlyphMaker;
@@@ -256,7 -259,7 +259,7 @@@ public class ECMA48 implements Runnabl
      /**
       * The type of emulator to be.
       */
-     private DeviceType type = DeviceType.VT102;
+     private final DeviceType type;
  
      /**
       * The scrollback buffer characters + attributes.
      /**
       * The maximum number of lines in the scrollback buffer.
       */
-     private int maxScrollback = 10000;
+     private int scrollbackMax = 10000;
  
      /**
       * The terminal's input.  For type == XTERM, this is an InputStreamReader
       * Physical display width.  We start at 80x24, but the user can resize us
       * bigger/smaller.
       */
-     private int width;
+     private int width = 80;
  
      /**
       * Physical display height.  We start at 80x24, but the user can resize
       * us bigger/smaller.
       */
-     private int height;
+     private int height = 24;
  
      /**
       * Top margin of the scrolling region.
       */
-     private int scrollRegionTop;
+     private int scrollRegionTop = 0;
  
      /**
       * Bottom margin of the scrolling region.
       */
-     private int scrollRegionBottom;
+     private int scrollRegionBottom = height - 1;
  
      /**
       * 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;
+     private int rightMargin = 79;
  
      /**
       * Last character printed.
       * 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;
+     private boolean wrapLineFlag = false;
  
      /**
       * VT220 single shift flag.
      /**
       * Non-csi collect buffer.
       */
-     private StringBuilder collectBuffer;
+     private StringBuilder collectBuffer = new StringBuilder(128);
  
      /**
       * When true, use the G1 character set.
      /**
       * Sixel collection buffer.
       */
-     private StringBuilder sixelParseBuffer;
+     private StringBuilder sixelParseBuffer = new StringBuilder(2048);
  
      /**
       * Sixel shared palette.
       */
      private ArrayList<TInputEvent> userQueue = new ArrayList<TInputEvent>();
  
+     /**
+      * Number of bytes/characters passed to consume().
+      */
+     private long readCount = 0;
      /**
       * DECSC/DECRC save/restore a subset of the total state.  This class
       * encapsulates those specific flags/modes.
              this.inputStream  = new TimeoutInputStream(inputStream, 2000);
          }
          if (type == DeviceType.XTERM) {
-             this.input    = new InputStreamReader(this.inputStream, "UTF-8");
+             this.input    = new InputStreamReader(new BufferedInputStream(
+                 this.inputStream, 1024 * 128), "UTF-8");
              this.output   = new OutputStreamWriter(new
                  BufferedOutputStream(outputStream), "UTF-8");
              this.outputStream = null;
          for (int i = 0; i < height; i++) {
              display.add(new DisplayLine(currentState.attr));
          }
+         assert (currentState.cursorY < height);
+         assert (currentState.cursorX < width);
  
          // Spin up the input reader
          readerThread = new Thread(this);
                                  int ch = Character.codePointAt(readBufferUTF8,
                                      i);
                                  i += Character.charCount(ch);
-                                 consume(ch);
+                                 // Special case for VT10x: 7-bit characters
+                                 // only.
+                                 if ((type == DeviceType.VT100)
+                                     || (type == DeviceType.VT102)
+                                 ) {
+                                     consume(ch & 0x7F);
+                                 } else {
+                                     consume(ch);
+                                 }
                              }
                          } else {
                              for (int i = 0; i < rc; i++) {
-                                 consume(readBuffer[i]);
+                                 // Special case for VT10x: 7-bit characters
+                                 // only.
+                                 if ((type == DeviceType.VT100)
+                                     || (type == DeviceType.VT102)
+                                 ) {
+                                     consume(readBuffer[i] & 0x7F);
+                                 } else {
+                                     consume(readBuffer[i]);
+                                 }
                              }
                          }
                      }
      // ECMA48 -----------------------------------------------------------------
      // ------------------------------------------------------------------------
  
+     /**
+      * Wait for a period of time to get output from the launched process.
+      *
+      * @param millis millis to wait for, or 0 to wait forever
+      * @return true if the launched process has emitted something
+      */
+     public boolean waitForOutput(final int millis) {
+         if (millis < 0) {
+             throw new IllegalArgumentException("timeout must be >= 0");
+         }
+         int waitedMillis = millis;
+         final int pollTimeout = 5;
+         while (true) {
+             if (readCount != 0) {
+                 return true;
+             }
+             if ((millis > 0) && (waitedMillis < 0)){
+                 return false;
+             }
+             try {
+                 Thread.sleep(pollTimeout);
+             } catch (InterruptedException e) {
+                 // SQUASH
+             }
+             waitedMillis -= pollTimeout;
+         }
+     }
      /**
       * Process keyboard and mouse events from the user.
       *
  
          case VT220:
          case XTERM:
-             // "I am a VT220" - 7 bit version
+             // "I am a VT220" - 7 bit version, with sixel and Jexer image
+             // support.
              if (!s8c1t) {
-                 return "\033[?62;1;6;9;4;22c";
-                 // return "\033[?62;1;6;9;4;22;444c";
+                 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";
+             // "I am a VT220" - 8 bit version, with sixel and Jexer image
+             // support.
+             return "\u009b?62;1;6;9;4;22;444c";
          default:
              throw new IllegalArgumentException("Invalid device type: " + type);
          }
          // the input streams.
          if (stopReaderThread == false) {
              stopReaderThread = true;
-             try {
-                 readerThread.join(1000);
-             } catch (InterruptedException e) {
-                 // SQUASH
-             }
          }
  
          // Now close the output stream.
          int delta = height - this.height;
          this.height = height;
          scrollRegionBottom += delta;
-         if (scrollRegionBottom < 0) {
-             scrollRegionBottom = height;
+         if ((scrollRegionBottom < 0) || (scrollRegionTop > height - 1)) {
+             scrollRegionBottom = height - 1;
          }
          if (scrollRegionTop >= scrollRegionBottom) {
              scrollRegionTop = 0;
              display.add(line);
          }
          while (display.size() > height) {
-             scrollback.add(display.remove(0));
+             appendScrollbackLine(display.remove(0));
          }
      }
  
+     /**
+      * Get the maximum number of lines in the scrollback buffer.
+      *
+      * @return the maximum number of lines in the scrollback buffer
+      */
+     public int getScrollbackMax() {
+         return scrollbackMax;
+     }
+     /**
+      * Set the maximum number of lines for the scrollback buffer.
+      *
+      * @param scrollbackMax the maximum number of lines for the scrollback
+      * buffer
+      */
+     public final void setScrollbackMax(final int scrollbackMax) {
+         this.scrollbackMax = scrollbackMax;
+     }
      /**
       * Get visible cursor flag.
       *
       */
      private void toGround() {
          csiParams.clear();
-         collectBuffer = new StringBuilder(8);
+         collectBuffer.setLength(0);
          scanState = ScanState.GROUND;
      }
  
              colors88.add(0);
          }
  
-         // Set default system colors.
+         // Set default system colors.  These match DOS colors.
          colors88.set(0, 0x00000000);
          colors88.set(1, 0x00a80000);
          colors88.set(2, 0x0000a800);
          colors88.set(13, 0x00fc54fc);
          colors88.set(14, 0x0054fcfc);
          colors88.set(15, 0x00fcfcfc);
+         // These match xterm's default colors from 256colres.h.
+         colors88.set(16, 0x000000);
+         colors88.set(17, 0x00005f);
+         colors88.set(18, 0x000087);
+         colors88.set(19, 0x0000af);
+         colors88.set(20, 0x0000d7);
+         colors88.set(21, 0x0000ff);
+         colors88.set(22, 0x005f00);
+         colors88.set(23, 0x005f5f);
+         colors88.set(24, 0x005f87);
+         colors88.set(25, 0x005faf);
+         colors88.set(26, 0x005fd7);
+         colors88.set(27, 0x005fff);
+         colors88.set(28, 0x008700);
+         colors88.set(29, 0x00875f);
+         colors88.set(30, 0x008787);
+         colors88.set(31, 0x0087af);
+         colors88.set(32, 0x0087d7);
+         colors88.set(33, 0x0087ff);
+         colors88.set(34, 0x00af00);
+         colors88.set(35, 0x00af5f);
+         colors88.set(36, 0x00af87);
+         colors88.set(37, 0x00afaf);
+         colors88.set(38, 0x00afd7);
+         colors88.set(39, 0x00afff);
+         colors88.set(40, 0x00d700);
+         colors88.set(41, 0x00d75f);
+         colors88.set(42, 0x00d787);
+         colors88.set(43, 0x00d7af);
+         colors88.set(44, 0x00d7d7);
+         colors88.set(45, 0x00d7ff);
+         colors88.set(46, 0x00ff00);
+         colors88.set(47, 0x00ff5f);
+         colors88.set(48, 0x00ff87);
+         colors88.set(49, 0x00ffaf);
+         colors88.set(50, 0x00ffd7);
+         colors88.set(51, 0x00ffff);
+         colors88.set(52, 0x5f0000);
+         colors88.set(53, 0x5f005f);
+         colors88.set(54, 0x5f0087);
+         colors88.set(55, 0x5f00af);
+         colors88.set(56, 0x5f00d7);
+         colors88.set(57, 0x5f00ff);
+         colors88.set(58, 0x5f5f00);
+         colors88.set(59, 0x5f5f5f);
+         colors88.set(60, 0x5f5f87);
+         colors88.set(61, 0x5f5faf);
+         colors88.set(62, 0x5f5fd7);
+         colors88.set(63, 0x5f5fff);
+         colors88.set(64, 0x5f8700);
+         colors88.set(65, 0x5f875f);
+         colors88.set(66, 0x5f8787);
+         colors88.set(67, 0x5f87af);
+         colors88.set(68, 0x5f87d7);
+         colors88.set(69, 0x5f87ff);
+         colors88.set(70, 0x5faf00);
+         colors88.set(71, 0x5faf5f);
+         colors88.set(72, 0x5faf87);
+         colors88.set(73, 0x5fafaf);
+         colors88.set(74, 0x5fafd7);
+         colors88.set(75, 0x5fafff);
+         colors88.set(76, 0x5fd700);
+         colors88.set(77, 0x5fd75f);
+         colors88.set(78, 0x5fd787);
+         colors88.set(79, 0x5fd7af);
+         colors88.set(80, 0x5fd7d7);
+         colors88.set(81, 0x5fd7ff);
+         colors88.set(82, 0x5fff00);
+         colors88.set(83, 0x5fff5f);
+         colors88.set(84, 0x5fff87);
+         colors88.set(85, 0x5fffaf);
+         colors88.set(86, 0x5fffd7);
+         colors88.set(87, 0x5fffff);
+         colors88.set(88, 0x870000);
+         colors88.set(89, 0x87005f);
+         colors88.set(90, 0x870087);
+         colors88.set(91, 0x8700af);
+         colors88.set(92, 0x8700d7);
+         colors88.set(93, 0x8700ff);
+         colors88.set(94, 0x875f00);
+         colors88.set(95, 0x875f5f);
+         colors88.set(96, 0x875f87);
+         colors88.set(97, 0x875faf);
+         colors88.set(98, 0x875fd7);
+         colors88.set(99, 0x875fff);
+         colors88.set(100, 0x878700);
+         colors88.set(101, 0x87875f);
+         colors88.set(102, 0x878787);
+         colors88.set(103, 0x8787af);
+         colors88.set(104, 0x8787d7);
+         colors88.set(105, 0x8787ff);
+         colors88.set(106, 0x87af00);
+         colors88.set(107, 0x87af5f);
+         colors88.set(108, 0x87af87);
+         colors88.set(109, 0x87afaf);
+         colors88.set(110, 0x87afd7);
+         colors88.set(111, 0x87afff);
+         colors88.set(112, 0x87d700);
+         colors88.set(113, 0x87d75f);
+         colors88.set(114, 0x87d787);
+         colors88.set(115, 0x87d7af);
+         colors88.set(116, 0x87d7d7);
+         colors88.set(117, 0x87d7ff);
+         colors88.set(118, 0x87ff00);
+         colors88.set(119, 0x87ff5f);
+         colors88.set(120, 0x87ff87);
+         colors88.set(121, 0x87ffaf);
+         colors88.set(122, 0x87ffd7);
+         colors88.set(123, 0x87ffff);
+         colors88.set(124, 0xaf0000);
+         colors88.set(125, 0xaf005f);
+         colors88.set(126, 0xaf0087);
+         colors88.set(127, 0xaf00af);
+         colors88.set(128, 0xaf00d7);
+         colors88.set(129, 0xaf00ff);
+         colors88.set(130, 0xaf5f00);
+         colors88.set(131, 0xaf5f5f);
+         colors88.set(132, 0xaf5f87);
+         colors88.set(133, 0xaf5faf);
+         colors88.set(134, 0xaf5fd7);
+         colors88.set(135, 0xaf5fff);
+         colors88.set(136, 0xaf8700);
+         colors88.set(137, 0xaf875f);
+         colors88.set(138, 0xaf8787);
+         colors88.set(139, 0xaf87af);
+         colors88.set(140, 0xaf87d7);
+         colors88.set(141, 0xaf87ff);
+         colors88.set(142, 0xafaf00);
+         colors88.set(143, 0xafaf5f);
+         colors88.set(144, 0xafaf87);
+         colors88.set(145, 0xafafaf);
+         colors88.set(146, 0xafafd7);
+         colors88.set(147, 0xafafff);
+         colors88.set(148, 0xafd700);
+         colors88.set(149, 0xafd75f);
+         colors88.set(150, 0xafd787);
+         colors88.set(151, 0xafd7af);
+         colors88.set(152, 0xafd7d7);
+         colors88.set(153, 0xafd7ff);
+         colors88.set(154, 0xafff00);
+         colors88.set(155, 0xafff5f);
+         colors88.set(156, 0xafff87);
+         colors88.set(157, 0xafffaf);
+         colors88.set(158, 0xafffd7);
+         colors88.set(159, 0xafffff);
+         colors88.set(160, 0xd70000);
+         colors88.set(161, 0xd7005f);
+         colors88.set(162, 0xd70087);
+         colors88.set(163, 0xd700af);
+         colors88.set(164, 0xd700d7);
+         colors88.set(165, 0xd700ff);
+         colors88.set(166, 0xd75f00);
+         colors88.set(167, 0xd75f5f);
+         colors88.set(168, 0xd75f87);
+         colors88.set(169, 0xd75faf);
+         colors88.set(170, 0xd75fd7);
+         colors88.set(171, 0xd75fff);
+         colors88.set(172, 0xd78700);
+         colors88.set(173, 0xd7875f);
+         colors88.set(174, 0xd78787);
+         colors88.set(175, 0xd787af);
+         colors88.set(176, 0xd787d7);
+         colors88.set(177, 0xd787ff);
+         colors88.set(178, 0xd7af00);
+         colors88.set(179, 0xd7af5f);
+         colors88.set(180, 0xd7af87);
+         colors88.set(181, 0xd7afaf);
+         colors88.set(182, 0xd7afd7);
+         colors88.set(183, 0xd7afff);
+         colors88.set(184, 0xd7d700);
+         colors88.set(185, 0xd7d75f);
+         colors88.set(186, 0xd7d787);
+         colors88.set(187, 0xd7d7af);
+         colors88.set(188, 0xd7d7d7);
+         colors88.set(189, 0xd7d7ff);
+         colors88.set(190, 0xd7ff00);
+         colors88.set(191, 0xd7ff5f);
+         colors88.set(192, 0xd7ff87);
+         colors88.set(193, 0xd7ffaf);
+         colors88.set(194, 0xd7ffd7);
+         colors88.set(195, 0xd7ffff);
+         colors88.set(196, 0xff0000);
+         colors88.set(197, 0xff005f);
+         colors88.set(198, 0xff0087);
+         colors88.set(199, 0xff00af);
+         colors88.set(200, 0xff00d7);
+         colors88.set(201, 0xff00ff);
+         colors88.set(202, 0xff5f00);
+         colors88.set(203, 0xff5f5f);
+         colors88.set(204, 0xff5f87);
+         colors88.set(205, 0xff5faf);
+         colors88.set(206, 0xff5fd7);
+         colors88.set(207, 0xff5fff);
+         colors88.set(208, 0xff8700);
+         colors88.set(209, 0xff875f);
+         colors88.set(210, 0xff8787);
+         colors88.set(211, 0xff87af);
+         colors88.set(212, 0xff87d7);
+         colors88.set(213, 0xff87ff);
+         colors88.set(214, 0xffaf00);
+         colors88.set(215, 0xffaf5f);
+         colors88.set(216, 0xffaf87);
+         colors88.set(217, 0xffafaf);
+         colors88.set(218, 0xffafd7);
+         colors88.set(219, 0xffafff);
+         colors88.set(220, 0xffd700);
+         colors88.set(221, 0xffd75f);
+         colors88.set(222, 0xffd787);
+         colors88.set(223, 0xffd7af);
+         colors88.set(224, 0xffd7d7);
+         colors88.set(225, 0xffd7ff);
+         colors88.set(226, 0xffff00);
+         colors88.set(227, 0xffff5f);
+         colors88.set(228, 0xffff87);
+         colors88.set(229, 0xffffaf);
+         colors88.set(230, 0xffffd7);
+         colors88.set(231, 0xffffff);
+         colors88.set(232, 0x080808);
+         colors88.set(233, 0x121212);
+         colors88.set(234, 0x1c1c1c);
+         colors88.set(235, 0x262626);
+         colors88.set(236, 0x303030);
+         colors88.set(237, 0x3a3a3a);
+         colors88.set(238, 0x444444);
+         colors88.set(239, 0x4e4e4e);
+         colors88.set(240, 0x585858);
+         colors88.set(241, 0x626262);
+         colors88.set(242, 0x6c6c6c);
+         colors88.set(243, 0x767676);
+         colors88.set(244, 0x808080);
+         colors88.set(245, 0x8a8a8a);
+         colors88.set(246, 0x949494);
+         colors88.set(247, 0x9e9e9e);
+         colors88.set(248, 0xa8a8a8);
+         colors88.set(249, 0xb2b2b2);
+         colors88.set(250, 0xbcbcbc);
+         colors88.set(251, 0xc6c6c6);
+         colors88.set(252, 0xd0d0d0);
+         colors88.set(253, 0xdadada);
+         colors88.set(254, 0xe4e4e4);
+         colors88.set(255, 0xeeeeee);
      }
  
      /**
          currentState            = new SaveableState();
          savedState              = new SaveableState();
          scanState               = ScanState.GROUND;
-         width                   = 80;
-         height                  = 24;
+         if (displayListener != null) {
+             width = displayListener.getDisplayWidth();
+             height = displayListener.getDisplayHeight();
+         } else {
+             width               = 80;
+             height              = 24;
+         }
          scrollRegionTop         = 0;
          scrollRegionBottom      = height - 1;
          rightMargin             = width - 1;
          arrowKeyMode            = ArrowKeyMode.ANSI;
          keypadMode              = KeypadMode.Numeric;
          wrapLineFlag            = false;
-         if (displayListener != null) {
-             width = displayListener.getDisplayWidth();
-             height = displayListener.getDisplayHeight();
-             rightMargin         = width - 1;
-         }
  
          // Flags
          shiftOut                = false;
          toGround();
      }
  
+     /**
+      * Append a to the scrollback buffer, clearing image data for lines more
+      * than three screenfuls in.
+      */
+     private void appendScrollbackLine(DisplayLine line) {
+         scrollback.add(line);
+         if (scrollback.size() > height * 3) {
+             scrollback.get(scrollback.size() - (height * 3)).clearImages();
+         }
+     }
      /**
       * 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) {
+         appendScrollbackLine(display.get(0));
+         while (scrollback.size() > scrollbackMax) {
              scrollback.remove(0);
              scrollback.trimToSize();
          }
       * Handle a linefeed.
       */
      private void linefeed() {
          if (currentState.cursorY < scrollRegionBottom) {
              // Increment screen y
              currentState.cursorY++;
          if (mouseEncoding == MouseEncoding.SGR) {
              sb.append((char) 0x1B);
              sb.append("[<");
+             int buttons = 0;
  
              if (mouse.isMouse1()) {
                  if (mouse.getType() == TMouseEvent.Type.MOUSE_MOTION) {
-                     sb.append("32;");
+                     buttons = 32;
                  } else {
-                     sb.append("0;");
+                     buttons = 0;
                  }
              } else if (mouse.isMouse2()) {
                  if (mouse.getType() == TMouseEvent.Type.MOUSE_MOTION) {
-                     sb.append("33;");
+                     buttons = 33;
                  } else {
-                     sb.append("1;");
+                     buttons = 1;
                  }
              } else if (mouse.isMouse3()) {
                  if (mouse.getType() == TMouseEvent.Type.MOUSE_MOTION) {
-                     sb.append("34;");
+                     buttons = 34;
                  } else {
-                     sb.append("2;");
+                     buttons = 2;
                  }
              } else if (mouse.isMouseWheelUp()) {
-                 sb.append("64;");
+                 buttons = 64;
              } else if (mouse.isMouseWheelDown()) {
-                 sb.append("65;");
+                 buttons = 65;
              } else {
                  // This is motion with no buttons down.
-                 sb.append("35;");
+                 buttons = 35;
+             }
+             if (mouse.isAlt()) {
+                 buttons |= 0x08;
+             }
+             if (mouse.isCtrl()) {
+                 buttons |= 0x10;
+             }
+             if (mouse.isShift()) {
+                 buttons |= 0x04;
              }
  
-             sb.append(String.format("%d;%d", mouse.getX() + 1,
+             sb.append(String.format("%d;%d;%d", buttons, mouse.getX() + 1,
                      mouse.getY() + 1));
  
              if (mouse.getType() == TMouseEvent.Type.MOUSE_UP) {
              sb.append((char) 0x1B);
              sb.append('[');
              sb.append('M');
+             int buttons = 0;
              if (mouse.getType() == TMouseEvent.Type.MOUSE_UP) {
-                 sb.append((char) (0x03 + 32));
+                 buttons = 0x03 + 32;
              } else if (mouse.isMouse1()) {
                  if (mouse.getType() == TMouseEvent.Type.MOUSE_MOTION) {
-                     sb.append((char) (0x00 + 32 + 32));
+                     buttons = 0x00 + 32 + 32;
                  } else {
-                     sb.append((char) (0x00 + 32));
+                     buttons = 0x00 + 32;
                  }
              } else if (mouse.isMouse2()) {
                  if (mouse.getType() == TMouseEvent.Type.MOUSE_MOTION) {
-                     sb.append((char) (0x01 + 32 + 32));
+                     buttons = 0x01 + 32 + 32;
                  } else {
-                     sb.append((char) (0x01 + 32));
+                     buttons = 0x01 + 32;
                  }
              } else if (mouse.isMouse3()) {
                  if (mouse.getType() == TMouseEvent.Type.MOUSE_MOTION) {
-                     sb.append((char) (0x02 + 32 + 32));
+                     buttons = 0x02 + 32 + 32;
                  } else {
-                     sb.append((char) (0x02 + 32));
+                     buttons = 0x02 + 32;
                  }
              } else if (mouse.isMouseWheelUp()) {
-                 sb.append((char) (0x04 + 64));
+                 buttons = 0x04 + 64;
              } else if (mouse.isMouseWheelDown()) {
-                 sb.append((char) (0x05 + 64));
+                 buttons = 0x05 + 64;
              } else {
                  // This is motion with no buttons down.
-                 sb.append((char) (0x03 + 32));
+                 buttons = 0x03 + 32;
+             }
+             if (mouse.isAlt()) {
+                 buttons |= 0x08;
+             }
+             if (mouse.isCtrl()) {
+                 buttons |= 0x10;
+             }
+             if (mouse.isShift()) {
+                 buttons |= 0x04;
              }
  
+             sb.append((char) (buttons & 0xFF));
              sb.append((char) (mouse.getX() + 33));
              sb.append((char) (mouse.getY() + 33));
          }
                      if (decPrivateModeFlag == true) {
                          if (value == true) {
                              // Enable sixel scrolling (default).
-                             // TODO
+                             // Not supported
                          } else {
                              // Disable sixel scrolling.
-                             // TODO
+                             // Not supported
                          }
                      }
                  }
                       * RGB color mode.
                       */
                      rgbColor = true;
-                     break;
+                     continue;
  
                  case 5:
                      /*
                       * Indexed color mode.
                       */
                      idx88Color = true;
-                     break;
+                     continue;
  
                  default:
                      /*
  
                  case 8:
                      // Invisible
-                     // TODO
+                     // Not supported
                      break;
  
                  case 90:
              // DECSTBM
              int top = getCsiParam(0, 1, 1, height) - 1;
              int bottom = getCsiParam(1, height, 1, height) - 1;
+             if (bottom > height - 1) {
+                 bottom = height - 1;
+             }
  
              if (top > bottom) {
                  top = bottom;
      private void oscPut(final char xtermChar) {
          // System.err.println("oscPut: " + xtermChar);
  
+         boolean oscEnd = false;
+         if (xtermChar == 0x07) {
+             oscEnd = true;
+         }
+         if ((xtermChar == '\\')
+             && (collectBuffer.charAt(collectBuffer.length() - 1) == '\033')
+         ) {
+             oscEnd = true;
+         }
          // Collect first
          collectBuffer.append(xtermChar);
  
          // Xterm cases...
-         if ((xtermChar == 0x07)
-             || (collectBuffer.toString().endsWith("\033\\"))
-         ) {
+         if (oscEnd) {
              String args = null;
              if (xtermChar == 0x07) {
                  args = collectBuffer.substring(0, collectBuffer.length() - 1);
                      }
                  }
  
-                 if (p[0].equals("444") && (p.length == 5)) {
-                     // Jexer image
-                     parseJexerImage(p[1], p[2], p[3], p[4]);
+                 if (p[0].equals("444")) {
+                     if (p[1].equals("0") && (p.length == 6)) {
+                         // Jexer image - RGB
+                         parseJexerImageRGB(p[2], p[3], p[4], p[5]);
+                     } else if (p[1].equals("1") && (p.length == 4)) {
+                         // Jexer image - PNG
+                         parseJexerImageFile(1, p[2], p[3]);
+                     } else if (p[1].equals("2") && (p.length == 4)) {
+                         // Jexer image - JPG
+                         parseJexerImageFile(2, p[2], p[3]);
+                     }
                  }
              }
  
              // Go to SCAN_GROUND state
      private void pmPut(final char pmChar) {
          // System.err.println("pmPut: " + pmChar);
  
+         boolean pmEnd = false;
+         if ((pmChar == '\\')
+             && (collectBuffer.charAt(collectBuffer.length() - 1) == '\033')
+         ) {
+             pmEnd = true;
+         }
          // Collect first
          collectBuffer.append(pmChar);
  
          // Xterm cases...
-         if (collectBuffer.toString().endsWith("\033\\")) {
+         if (pmEnd) {
              String arg = null;
              arg = collectBuffer.substring(0, collectBuffer.length() - 2);
  
       *
       * @param ch character from the remote side
       */
-     private void consume(int ch) {
+     private void consume(final int ch) {
+         readCount++;
  
          // 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
  
              // 0x71 goes to DCS_SIXEL
              if (ch == 0x71) {
-                 sixelParseBuffer = new StringBuilder();
+                 sixelParseBuffer.setLength(0);
                  scanState = ScanState.DCS_SIXEL;
              } else if ((ch >= 0x40) && (ch <= 0x7E)) {
                  // 0x40-7E goes to DCS_PASSTHROUGH
  
              // 0x71 goes to DCS_SIXEL
              if (ch == 0x71) {
-                 sixelParseBuffer = new StringBuilder();
+                 sixelParseBuffer.setLength(0);
                  scanState = ScanState.DCS_SIXEL;
              } else if ((ch >= 0x40) && (ch <= 0x7E)) {
                  // 0x40-7E goes to DCS_PASSTHROUGH
              // 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);
+         if ((image.getWidth() < 1)
+             || (image.getWidth() > 10000)
+             || (image.getHeight() < 1)
+             || (image.getHeight() > 10000)
+         ) {
+             return;
          }
  
+         imageToCells(image, true);
      }
  
      /**
-      * Parse a "Jexer" image string into a bitmap image, and overlay that
+      * Parse a "Jexer" RGB image string into a bitmap image, and overlay that
       * image onto the text cells.
       *
       * @param pw width token
       * @param ps scroll token
       * @param data pixel data
       */
-     private void parseJexerImage(final String pw, final String ph,
+     private void parseJexerImageRGB(final String pw, final String ph,
          final String ps, final String data) {
  
          int imageWidth = 0;
              return;
          }
  
-         java.util.Base64.Decoder base64 = java.util.Base64.getDecoder();
-         byte [] bytes = base64.decode(data);
+         byte [] bytes = StringUtils.fromBase64(data.getBytes());
          if (bytes.length != (imageWidth * imageHeight * 3)) {
              return;
          }
              }
          }
  
+         imageToCells(image, scroll);
+     }
+     /**
+      * Parse a "Jexer" PNG or JPG image string into a bitmap image, and
+      * overlay that image onto the text cells.
+      *
+      * @param type 1 for PNG, 2 for JPG
+      * @param ps scroll token
+      * @param data pixel data
+      */
+     private void parseJexerImageFile(final int type, final String ps,
+         final String data) {
+         int imageWidth = 0;
+         int imageHeight = 0;
+         boolean scroll = false;
+         BufferedImage image = null;
+         try {
+             byte [] bytes = StringUtils.fromBase64(data.getBytes());
+             switch (type) {
+             case 1:
+                 if ((bytes[0] != (byte) 0x89)
+                     || (bytes[1] != 'P')
+                     || (bytes[2] != 'N')
+                     || (bytes[3] != 'G')
+                     || (bytes[4] != (byte) 0x0D)
+                     || (bytes[5] != (byte) 0x0A)
+                     || (bytes[6] != (byte) 0x1A)
+                     || (bytes[7] != (byte) 0x0A)
+                 ) {
+                     // File does not have PNG header, bail out.
+                     return;
+                 }
+                 break;
+             case 2:
+                 if ((bytes[0] != (byte) 0XFF)
+                     || (bytes[1] != (byte) 0xD8)
+                     || (bytes[2] != (byte) 0xFF)
+                 ) {
+                     // File does not have JPG header, bail out.
+                     return;
+                 }
+                 break;
+             default:
+                 // Unsupported type, bail out.
+                 return;
+             }
+             image = ImageIO.read(new ByteArrayInputStream(bytes));
+         } catch (IOException e) {
+             // SQUASH
+             return;
+         }
+         assert (image != null);
+         imageWidth = image.getWidth();
+         imageHeight = image.getHeight();
+         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;
+         }
+         imageToCells(image, scroll);
+     }
+     /**
+      * Break up an image into the cells at the current cursor.
+      *
+      * @param image the image to display
+      * @param scroll if true, scroll the image and move the cursor
+      */
+     private void imageToCells(final BufferedImage image, final boolean scroll) {
+         assert (image != null);
          /*
           * Procedure:
           *
                  }
  
                  Cell cell = new Cell();
-                 cell.setImage(image.getSubimage(x * textWidth,
-                         y * textHeight, width, height));
+                 if ((width != textWidth) || (height != textHeight)) {
+                     BufferedImage newImage;
+                     newImage = new BufferedImage(textWidth, textHeight,
+                         BufferedImage.TYPE_INT_ARGB);
+                     Graphics gr = newImage.getGraphics();
+                     gr.drawImage(image.getSubimage(x * textWidth,
+                             y * textHeight, width, height),
+                         0, 0, null, null);
+                     gr.dispose();
+                     cell.setImage(newImage);
+                 } else {
+                     cell.setImage(image.getSubimage(x * textWidth,
+                             y * textHeight, width, height));
+                 }
  
                  cells[x][y] = cell;
              }
          }
  
          int x0 = currentState.cursorX;
+         int y0 = currentState.cursorY;
          for (int y = 0; y < cellRows; y++) {
              for (int x = 0; x < cellColumns; x++) {
                  assert (currentState.cursorX <= rightMargin);
+                 // A real sixel terminal would render the text of the current
+                 // cell first, then image over it (accounting for blank
+                 // pixels).  We do not support that.  A cell is either text,
+                 // or image, but not a mix of image-over-text.
                  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))
-             ) {
+             if (currentState.cursorY < scrollRegionBottom - 1) {
+                 // Not at the bottom, down a line.
                  linefeed();
+             } else if (scroll == true) {
+                 // At the bottom, scroll as needed.
+                 linefeed();
+             } else {
+                 // At the bottom, no more scrolling, done.
+                 break;
              }
              cursorPosition(currentState.cursorY, x0);
          }
  
+         if (scroll == false) {
+             cursorPosition(y0, x0);
+         }
      }
  
  }
diff --combined tterminal/Sixel.java
index a4c00fc67f1da1ca38847b9bb5dad63daac5e006,b91e77a98beebc8cdfc726426654fd07c1b43967..b91e77a98beebc8cdfc726426654fd07c1b43967
@@@ -31,7 -31,6 +31,6 @@@ package jexer.tterminal
  import java.awt.Color;
  import java.awt.Graphics2D;
  import java.awt.image.BufferedImage;
- import java.util.ArrayList;
  import java.util.HashMap;
  
  /**
@@@ -574,10 -573,10 +573,10 @@@ public class Sixel 
          case REPEAT:
              if ((ch >= '0') && (ch <= '9')) {
                  if (repeatCount == -1) {
-                     repeatCount = (int) (ch - '0');
+                     repeatCount = (ch - '0');
                  } else {
                      repeatCount *= 10;
-                     repeatCount += (int) (ch - '0');
+                     repeatCount += (ch - '0');
                  }
              }
              return;
index 080a200497dfbe5389a8f62f3593688eb325fb72,13beac321bac9e46b0f81af48ac069efe3f5c890..13beac321bac9e46b0f81af48ac069efe3f5c890
@@@ -268,11 -268,55 +268,55 @@@ public class TTreeViewWidget extends TS
      // 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);
+         }
+         if (treeView != null) {
+             treeView.setWidth(getWidth() - 1);
+         }
+         reflowData();
+     }
+     /**
+      * Override TWidget's height: we need to set child widget heights.
+      *
+      * @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);
+         }
+         if (treeView != null) {
+             treeView.setHeight(getHeight() - 1);
+         }
+         reflowData();
+     }
      /**
       * Resize text and scrollbars for a new width/height.
       */
      @Override
      public void reflowData() {
+         if (treeView == null) {
+             return;
+         }
          int selectedRow = 0;
          boolean foundSelectedRow = false;