sixel support for TTerminalWindow
[fanfix.git] / src / jexer / TTerminalWindow.java
index 93d0fa1575a5a6e550ef78b26db87d7f22a081d7..9780e29d7d6643ad0c032801c6605d3320b91052 100644 (file)
@@ -3,7 +3,7 @@
  *
  * The MIT License (MIT)
  *
- * Copyright (C) 2017 Kevin Lamonte
+ * Copyright (C) 2019 Kevin Lamonte
  *
  * Permission is hereby granted, free of charge, to any person obtaining a
  * copy of this software and associated documentation files (the "Software"),
  */
 package jexer;
 
+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.LinkedList;
+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.MultiScreen;
+import jexer.backend.SwingTerminal;
 import jexer.bits.Cell;
 import jexer.bits.CellAttributes;
 import jexer.event.TKeypressEvent;
@@ -54,7 +64,6 @@ import static jexer.TKeypress.*;
 public class TTerminalWindow extends TScrollableWindow
                              implements DisplayListener {
 
-
     /**
      * Translated strings.
      */
@@ -77,10 +86,64 @@ public class TTerminalWindow extends TScrollableWindow
     /**
      * If true, we are using the ptypipe utility to support dynamic window
      * resizing.  ptypipe is available at
-     * https://github.com/klamonte/ptypipe .
+     * https://gitlab.com/klamonte/ptypipe .
      */
     private boolean ptypipe = false;
 
+    /**
+     * If true, close the window when the shell exits.
+     */
+    private boolean closeOnExit = false;
+
+    /**
+     * System-dependent Y adjustment for text in the character cell
+     * (double-height).
+     */
+    private int doubleTextAdjustY = 0;
+
+    /**
+     * System-dependent X adjustment for text in the character cell
+     * (double-height).
+     */
+    private int doubleTextAdjustX = 0;
+
+    /**
+     * Descent of a character cell in pixels (double-height).
+     */
+    private int doubleMaxDescent = 0;
+
+    /**
+     * Double-width font.
+     */
+    private Font doubleFont = null;
+
+    /**
+     * Last text width value.
+     */
+    private int lastTextWidth = -1;
+
+    /**
+     * Last text height value.
+     */
+    private int lastTextHeight = -1;
+
+    /**
+     * A cache of previously-rendered double-width glyphs.
+     */
+    private Map<Cell, BufferedImage> glyphCache;
+
+    /**
+     * A cache of previously-rendered double-width glyphs for blinking text,
+     * when it is not visible.
+     */
+    private Map<Cell, BufferedImage> glyphCacheBlink;
+
+    /**
+     * The blink state, used only by ECMA48 backend and when double-width
+     * chars must be drawn.
+     */
+    private boolean blinkState = true;
+
     // ------------------------------------------------------------------------
     // Constructors -----------------------------------------------------------
     // ------------------------------------------------------------------------
@@ -96,7 +159,25 @@ public class TTerminalWindow extends TScrollableWindow
     public TTerminalWindow(final TApplication application, final int x,
         final int y, final String commandLine) {
 
-        this(application, x, y, RESIZABLE, commandLine.split("\\s"));
+        this(application, x, y, RESIZABLE, commandLine.split("\\s+"),
+            System.getProperty("jexer.TTerminal.closeOnExit",
+                "false").equals("true"));
+    }
+
+    /**
+     * Public constructor spawns a custom command line.
+     *
+     * @param application TApplication that manages this window
+     * @param x column relative to parent
+     * @param y row relative to parent
+     * @param commandLine the command line to execute
+     * @param closeOnExit if true, close the window when the command exits
+     */
+    public TTerminalWindow(final TApplication application, final int x,
+        final int y, final String commandLine, final boolean closeOnExit) {
+
+        this(application, x, y, RESIZABLE, commandLine.split("\\s+"),
+            closeOnExit);
     }
 
     /**
@@ -111,9 +192,30 @@ public class TTerminalWindow extends TScrollableWindow
     public TTerminalWindow(final TApplication application, final int x,
         final int y, final int flags, final String [] command) {
 
+        this(application, x, y, flags, command,
+            System.getProperty("jexer.TTerminal.closeOnExit",
+                "false").equals("true"));
+    }
+
+    /**
+     * Public constructor spawns a custom command line.
+     *
+     * @param application TApplication that manages this window
+     * @param x column relative to parent
+     * @param y row relative to parent
+     * @param flags mask of CENTERED, MODAL, or RESIZABLE
+     * @param command the command line to execute
+     * @param closeOnExit if true, close the window when the command exits
+     */
+    public TTerminalWindow(final TApplication application, final int x,
+        final int y, final int flags, final String [] command,
+        final boolean closeOnExit) {
+
         super(application, i18n.getString("windowTitle"), x, y,
             80 + 2, 24 + 2, flags);
 
+        this.closeOnExit = closeOnExit;
+
         String [] fullCommand;
 
         // Spawn a shell and pass its I/O to the other constructor.
@@ -161,9 +263,29 @@ public class TTerminalWindow extends TScrollableWindow
     public TTerminalWindow(final TApplication application, final int x,
         final int y, final int flags) {
 
+        this(application, x, y, flags,
+            System.getProperty("jexer.TTerminal.closeOnExit",
+                "false").equals("true"));
+
+    }
+
+    /**
+     * Public constructor spawns a shell.
+     *
+     * @param application TApplication that manages this window
+     * @param x column relative to parent
+     * @param y row relative to parent
+     * @param flags mask of CENTERED, MODAL, or RESIZABLE
+     * @param closeOnExit if true, close the window when the shell exits
+     */
+    public TTerminalWindow(final TApplication application, final int x,
+        final int y, final int flags, final boolean closeOnExit) {
+
         super(application, i18n.getString("windowTitle"), x, y,
             80 + 2, 24 + 2, flags);
 
+        this.closeOnExit = closeOnExit;
+
         String cmdShellWindows = "cmd.exe";
 
         // You cannot run a login shell in a bare Process interactively, due
@@ -183,16 +305,16 @@ public class TTerminalWindow extends TScrollableWindow
                 equals("true"))
         ) {
             ptypipe = true;
-            spawnShell(cmdShellPtypipe.split("\\s"));
+            spawnShell(cmdShellPtypipe.split("\\s+"));
         } else if (System.getProperty("os.name").startsWith("Windows")) {
-            spawnShell(cmdShellWindows.split("\\s"));
+            spawnShell(cmdShellWindows.split("\\s+"));
         } else if (System.getProperty("os.name").startsWith("Mac")) {
-            spawnShell(cmdShellBSD.split("\\s"));
+            spawnShell(cmdShellBSD.split("\\s+"));
         } else if (System.getProperty("os.name").startsWith("Linux")) {
-            spawnShell(cmdShellGNU.split("\\s"));
+            spawnShell(cmdShellGNU.split("\\s+"));
         } else {
             // When all else fails, assume GNU.
-            spawnShell(cmdShellGNU.split("\\s"));
+            spawnShell(cmdShellGNU.split("\\s+"));
         }
     }
 
@@ -224,7 +346,7 @@ public class TTerminalWindow extends TScrollableWindow
                 + getVerticalValue();
             assert (visibleBottom >= 0);
 
-            List<DisplayLine> preceedingBlankLines = new LinkedList<DisplayLine>();
+            List<DisplayLine> preceedingBlankLines = new ArrayList<DisplayLine>();
             int visibleTop = visibleBottom - visibleHeight;
             if (visibleTop < 0) {
                 for (int i = visibleTop; i < 0; i++) {
@@ -234,11 +356,11 @@ public class TTerminalWindow extends TScrollableWindow
             }
             assert (visibleTop >= 0);
 
-            List<DisplayLine> displayLines = new LinkedList<DisplayLine>();
+            List<DisplayLine> displayLines = new ArrayList<DisplayLine>();
             displayLines.addAll(scrollback);
             displayLines.addAll(display);
 
-            List<DisplayLine> visibleLines = new LinkedList<DisplayLine>();
+            List<DisplayLine> visibleLines = new ArrayList<DisplayLine>();
             visibleLines.addAll(preceedingBlankLines);
             visibleLines.addAll(displayLines.subList(visibleTop,
                     visibleBottom));
@@ -277,10 +399,9 @@ public class TTerminalWindow extends TScrollableWindow
                         }
                     }
                     if (line.isDoubleWidth()) {
-                        getScreen().putCharXY((i * 2) + 1, row, newCell);
-                        getScreen().putCharXY((i * 2) + 2, row, ' ', newCell);
+                        putDoubleWidthCharXY(line, (i * 2) + 1, row, newCell);
                     } else {
-                        getScreen().putCharXY(i + 1, row, newCell);
+                        putCharXY(i + 1, row, newCell);
                     }
                 }
                 row++;
@@ -292,14 +413,26 @@ public class TTerminalWindow extends TScrollableWindow
             CellAttributes background = new CellAttributes();
             // Fill in the blank lines on bottom
             for (int i = 0; i < visibleHeight; i++) {
-                getScreen().hLineXY(1, i + row, getWidth() - 2, ' ',
-                    background);
+                hLineXY(1, i + row, getWidth() - 2, ' ', background);
             }
 
         } // synchronized (emulator)
 
     }
 
+    /**
+     * Handle window close.
+     */
+    @Override
+    public void onClose() {
+        emulator.close();
+        if (shell != null) {
+            terminateShellChildProcess();
+            shell.destroy();
+            shell = null;
+        }
+    }
+
     /**
      * Handle window/screen resize events.
      *
@@ -355,19 +488,6 @@ public class TTerminalWindow extends TScrollableWindow
         } // synchronized (emulator)
     }
 
-    /**
-     * Handle window close.
-     */
-    @Override
-    public void onClose() {
-        emulator.close();
-        if (shell != null) {
-            terminateShellChildProcess();
-            shell.destroy();
-            shell = null;
-        }
-    }
-
     /**
      * Handle keystrokes.
      *
@@ -430,13 +550,17 @@ public class TTerminalWindow extends TScrollableWindow
             return;
         }
 
-        if (mouse.isMouseWheelUp()) {
-            verticalDecrement();
-            return;
-        }
-        if (mouse.isMouseWheelDown()) {
-            verticalIncrement();
-            return;
+        // If the emulator is tracking mouse buttons, it needs to see wheel
+        // events.
+        if (emulator.getMouseProtocol() == ECMA48.MouseProtocol.OFF) {
+            if (mouse.isMouseWheelUp()) {
+                verticalDecrement();
+                return;
+            }
+            if (mouse.isMouseWheelDown()) {
+                verticalIncrement();
+                return;
+            }
         }
         if (mouseOnEmulator(mouse)) {
             synchronized (emulator) {
@@ -641,6 +765,22 @@ public class TTerminalWindow extends TScrollableWindow
 
         // Add shortcut text
         newStatusBar(i18n.getString("statusBarRunning"));
+
+        // Pass the correct text cell width/height to the emulator
+        int textWidth = 16;
+        int textHeight = 20;
+        if (getScreen() instanceof SwingTerminal) {
+            SwingTerminal terminal = (SwingTerminal) getScreen();
+
+            textWidth = terminal.getTextWidth();
+            textHeight = terminal.getTextHeight();
+        } else if (getScreen() instanceof ECMA48Terminal) {
+            ECMA48Terminal terminal = (ECMA48Terminal) getScreen();
+            textWidth = terminal.getTextWidth();
+            textHeight = terminal.getTextHeight();
+        }
+        emulator.setTextWidth(textWidth);
+        emulator.setTextHeight(textHeight);
     }
 
     /**
@@ -711,6 +851,9 @@ public class TTerminalWindow extends TScrollableWindow
      * Hook for subclasses to be notified of the shell termination.
      */
     public void onShellExit() {
+        if (closeOnExit) {
+            close();
+        }
         getApplication().postEvent(new TMenuEvent(TMenu.MID_REPAINT));
     }
 
@@ -722,6 +865,7 @@ public class TTerminalWindow extends TScrollableWindow
         // Synchronize against the emulator so we don't stomp on its reader
         // thread.
         synchronized (emulator) {
+            setHiddenMouse(emulator.hasHiddenMousePointer());
 
             setCursorX(emulator.getCursorX() + 1);
             setCursorY(emulator.getCursorY() + 1
@@ -787,7 +931,7 @@ public class TTerminalWindow extends TScrollableWindow
      * @param mouse a mouse-based event
      * @return whether or not the mouse is on the emulator
      */
-    private final boolean mouseOnEmulator(final TMouseEvent mouse) {
+    private boolean mouseOnEmulator(final TMouseEvent mouse) {
 
         synchronized (emulator) {
             if (!emulator.isReading()) {
@@ -805,4 +949,196 @@ public class TTerminalWindow extends TScrollableWindow
         return false;
     }
 
+    /**
+     * Draw glyphs for a double-width or double-height VT100 cell to two
+     * screen cells.
+     *
+     * @param line the line this VT100 cell is in
+     * @param x the X position to draw the left half to
+     * @param y the Y position to draw to
+     * @param cell the cell to draw
+     */
+    private void putDoubleWidthCharXY(final DisplayLine line, final int x,
+        final int y, final Cell cell) {
+
+        int textWidth = 16;
+        int textHeight = 20;
+        boolean cursorBlinkVisible = true;
+
+        if (getScreen() instanceof SwingTerminal) {
+            SwingTerminal terminal = (SwingTerminal) getScreen();
+
+            textWidth = terminal.getTextWidth();
+            textHeight = terminal.getTextHeight();
+            cursorBlinkVisible = terminal.getCursorBlinkVisible();
+        } else if (getScreen() instanceof ECMA48Terminal) {
+            ECMA48Terminal terminal = (ECMA48Terminal) getScreen();
+
+            if (!terminal.hasSixel()) {
+                // The backend does not have sixel support, draw this as text
+                // and bail out.
+                putCharXY(x, y, cell);
+                putCharXY(x + 1, y, ' ', cell);
+                return;
+            }
+
+            textWidth = terminal.getTextWidth();
+            textHeight = terminal.getTextHeight();
+            cursorBlinkVisible = blinkState;
+        } else {
+            // We don't know how to dray glyphs to this screen, draw them as
+            // text and bail out.
+            putCharXY(x, y, cell);
+            putCharXY(x + 1, y, ' ', cell);
+            return;
+        }
+
+        if ((textWidth != lastTextWidth) || (textHeight != lastTextHeight)) {
+            // Screen size has changed, reset all fonts.
+            setupFonts(textHeight);
+            lastTextWidth = textWidth;
+            lastTextHeight = textHeight;
+        }
+        assert (doubleFont != null);
+
+        BufferedImage image = null;
+        if (cell.isBlink() && !cursorBlinkVisible) {
+            image = glyphCacheBlink.get(cell);
+        } else {
+            image = glyphCache.get(cell);
+        }
+        if (image == null) {
+            // Generate glyph and draw it to an image.
+            image = new BufferedImage(textWidth * 2, textHeight * 2,
+                BufferedImage.TYPE_INT_ARGB);
+            Graphics2D gr2 = image.createGraphics();
+            gr2.setFont(doubleFont);
+
+            // Draw the background rectangle, then the foreground character.
+            if (getScreen() instanceof ECMA48Terminal) {
+                // BUG: the background color is coming in the same as the
+                // foreground color.  For now, don't draw it.
+            } else {
+                gr2.setColor(SwingTerminal.attrToBackgroundColor(cell));
+                gr2.fillRect(0, 0, image.getWidth(), image.getHeight());
+            }
+            if (!cell.isBlink()
+                || (cell.isBlink() && cursorBlinkVisible)
+            ) {
+                gr2.setColor(SwingTerminal.attrToForegroundColor(cell));
+                char [] chars = new char[1];
+                chars[0] = cell.getChar();
+                gr2.drawChars(chars, 0, 1, doubleTextAdjustX,
+                    (textHeight * 2) - doubleMaxDescent + doubleTextAdjustY);
+
+                if (cell.isUnderline() && (line.getDoubleHeight() != 1)) {
+                    gr2.fillRect(0, textHeight - 2, textWidth, 2);
+                }
+            }
+            gr2.dispose();
+
+            // Now save this generated image, using a new key that will not
+            // be mutated by invertCell().
+            Cell key = new Cell();
+            key.setTo(cell);
+            if (cell.isBlink() && !cursorBlinkVisible) {
+                glyphCacheBlink.put(key, image);
+            } else {
+                glyphCache.put(key, image);
+            }
+        }
+
+        // Now that we have the double-wide glyph drawn, copy the right
+        // pieces of it to the cells.
+        Cell left = new Cell();
+        Cell right = new Cell();
+        left.setTo(cell);
+        right.setTo(cell);
+        right.setChar(' ');
+        BufferedImage leftImage = null;
+        BufferedImage rightImage = null;
+        switch (line.getDoubleHeight()) {
+        case 1:
+            // Top half double height
+            leftImage = image.getSubimage(0, 0, textWidth, textHeight);
+            rightImage = image.getSubimage(textWidth, 0, textWidth, textHeight);
+            break;
+        case 2:
+            // Bottom half double height
+            leftImage = image.getSubimage(0, textHeight, textWidth, textHeight);
+            rightImage = image.getSubimage(textWidth, textHeight,
+                textWidth, textHeight);
+            break;
+        default:
+            // Either single height double-width, or error fallback
+            BufferedImage wideImage = new BufferedImage(textWidth * 2,
+                textHeight, BufferedImage.TYPE_INT_ARGB);
+            Graphics2D grWide = wideImage.createGraphics();
+            grWide.drawImage(image, 0, 0, wideImage.getWidth(),
+                wideImage.getHeight(), null);
+            grWide.dispose();
+            leftImage = wideImage.getSubimage(0, 0, textWidth, textHeight);
+            rightImage = wideImage.getSubimage(textWidth, 0, textWidth,
+                textHeight);
+            break;
+        }
+        left.setImage(leftImage);
+        right.setImage(rightImage);
+        putCharXY(x, y, left);
+        putCharXY(x + 1, y, right);
+    }
+
+    /**
+     * Set up the single and double-width fonts.
+     *
+     * @param fontSize the size of font to request for the single-width font.
+     * The double-width font will be 2x this value.
+     */
+    private void setupFonts(final int fontSize) {
+        try {
+            ClassLoader loader = Thread.currentThread().getContextClassLoader();
+            InputStream in = loader.getResourceAsStream(SwingTerminal.FONTFILE);
+            Font terminusRoot = Font.createFont(Font.TRUETYPE_FONT, in);
+            Font terminusDouble = terminusRoot.deriveFont(Font.PLAIN,
+                fontSize * 2);
+            doubleFont = terminusDouble;
+        } catch (java.awt.FontFormatException e) {
+            new TExceptionDialog(getApplication(), e);
+            doubleFont = new Font(Font.MONOSPACED, Font.PLAIN, fontSize * 2);
+        } catch (java.io.IOException e) {
+            new TExceptionDialog(getApplication(), e);
+            doubleFont = new Font(Font.MONOSPACED, Font.PLAIN, fontSize * 2);
+        }
+
+        // Get font descent.
+        BufferedImage image = new BufferedImage(fontSize * 10, fontSize * 10,
+            BufferedImage.TYPE_INT_ARGB);
+        Graphics2D gr = image.createGraphics();
+        gr.setFont(doubleFont);
+        FontMetrics fm = gr.getFontMetrics();
+        doubleMaxDescent = fm.getMaxDescent();
+
+        gr.dispose();
+
+        // (Re)create the glyph caches.
+        glyphCache = new HashMap<Cell, BufferedImage>();
+        glyphCacheBlink = new HashMap<Cell, BufferedImage>();
+
+        // Special case: the ECMA48 backend needs to have a timer to drive
+        // its blink state.
+        if (getScreen() instanceof jexer.backend.ECMA48Terminal) {
+            // Blink every 500 millis.
+            long millis = 500;
+            getApplication().addTimer(millis, true,
+                new TAction() {
+                    public void DO() {
+                        blinkState = !blinkState;
+                        getApplication().doRepaint();
+                    }
+                }
+            );
+        }
+
+    }
+
 }