From 5fc7bf09f3c9987287f34f9035b522b0e5e9de13 Mon Sep 17 00:00:00 2001 From: Kevin Lamonte Date: Sun, 4 Aug 2019 07:52:47 -0500 Subject: [PATCH] TTerminalWindow sixel support wip --- src/jexer/TTerminalWindow.java | 26 +- src/jexer/backend/ECMA48Terminal.java | 9 + src/jexer/tterminal/ECMA48.java | 199 +++++++++- src/jexer/tterminal/Sixel.java | 523 ++++++++++++++++++++++++++ 4 files changed, 750 insertions(+), 7 deletions(-) create mode 100644 src/jexer/tterminal/Sixel.java diff --git a/src/jexer/TTerminalWindow.java b/src/jexer/TTerminalWindow.java index b36e86b6..818a52f6 100644 --- a/src/jexer/TTerminalWindow.java +++ b/src/jexer/TTerminalWindow.java @@ -28,10 +28,10 @@ */ package jexer; -import java.awt.image.BufferedImage; 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; @@ -761,6 +761,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); } /** @@ -954,6 +970,14 @@ public class TTerminalWindow extends TScrollableWindow } 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; diff --git a/src/jexer/backend/ECMA48Terminal.java b/src/jexer/backend/ECMA48Terminal.java index 60855549..08010cef 100644 --- a/src/jexer/backend/ECMA48Terminal.java +++ b/src/jexer/backend/ECMA48Terminal.java @@ -3012,6 +3012,15 @@ public class ECMA48Terminal extends LogicalScreen return (startSixel(x, y) + sb.toString() + endSixel()); } + /** + * Get the sixel support flag. + * + * @return true if this terminal is emitting sixel + */ + public boolean hasSixel() { + return sixel; + } + // ------------------------------------------------------------------------ // End sixel output support ----------------------------------------------- // ------------------------------------------------------------------------ diff --git a/src/jexer/tterminal/ECMA48.java b/src/jexer/tterminal/ECMA48.java index eb13c0b4..f2b485ec 100644 --- a/src/jexer/tterminal/ECMA48.java +++ b/src/jexer/tterminal/ECMA48.java @@ -28,6 +28,8 @@ */ package jexer.tterminal; +import java.awt.Graphics2D; +import java.awt.image.BufferedImage; import java.io.BufferedOutputStream; import java.io.CharArrayWriter; import java.io.InputStream; @@ -41,6 +43,7 @@ import java.io.UnsupportedEncodingException; import java.io.Writer; import java.util.ArrayList; import java.util.Collections; +import java.util.HashMap; import java.util.List; import jexer.TKeypress; @@ -136,6 +139,7 @@ public class ECMA48 implements Runnable { DCS_PARAM, DCS_PASSTHROUGH, DCS_IGNORE, + DCS_SIXEL, SOSPMAPC_STRING, OSC_STRING, VT52_DIRECT_CURSOR_ADDRESS @@ -458,6 +462,21 @@ public class ECMA48 implements Runnable { */ private List colors88; + /** + * Sixel collection buffer. + */ + private StringBuilder sixelParseBuffer; + + /** + * The width of a character cell in pixels. + */ + private int textWidth = 16; + + /** + * The height of a character cell in pixels. + */ + private int textHeight = 20; + /** * DECSC/DECRC save/restore a subset of the total state. This class * encapsulates those specific flags/modes. @@ -4609,7 +4628,7 @@ public class ECMA48 implements Runnable { private void consume(char ch) { // DEBUG - // System.err.printf("%c", ch); + // System.err.printf("%c STATE = %s\n", ch, scanState); // Special case for VT10x: 7-bit characters only if ((type == DeviceType.VT100) || (type == DeviceType.VT102)) { @@ -4631,9 +4650,11 @@ public class ECMA48 implements Runnable { if (ch == 0x1B) { if ((type == DeviceType.XTERM) && ((scanState == ScanState.OSC_STRING) + || (scanState == ScanState.DCS_SIXEL) || (scanState == ScanState.SOSPMAPC_STRING)) ) { // Xterm can pass ESCAPE to its OSC sequence. + // Xterm can pass ESCAPE to its DCS sequence. // Jexer can pass ESCAPE to its PM sequence. } else if ((scanState != ScanState.DCS_ENTRY) && (scanState != ScanState.DCS_INTERMEDIATE) @@ -4641,7 +4662,6 @@ public class ECMA48 implements Runnable { && (scanState != ScanState.DCS_PARAM) && (scanState != ScanState.DCS_PASSTHROUGH) ) { - scanState = ScanState.ESCAPE; return; } @@ -6353,8 +6373,12 @@ public class ECMA48 implements Runnable { scanState = ScanState.DCS_IGNORE; } - // 0x40-7E goes to DCS_PASSTHROUGH - if ((ch >= 0x40) && (ch <= 0x7E)) { + // 0x71 goes to DCS_SIXEL + if (ch == 0x71) { + sixelParseBuffer = new StringBuilder(); + scanState = ScanState.DCS_SIXEL; + } else if ((ch >= 0x40) && (ch <= 0x7E)) { + // 0x40-7E goes to DCS_PASSTHROUGH scanState = ScanState.DCS_PASSTHROUGH; } return; @@ -6434,8 +6458,12 @@ public class ECMA48 implements Runnable { scanState = ScanState.DCS_IGNORE; } - // 0x40-7E goes to DCS_PASSTHROUGH - if ((ch >= 0x40) && (ch <= 0x7E)) { + // 0x71 goes to DCS_SIXEL + if (ch == 0x71) { + sixelParseBuffer = new StringBuilder(); + scanState = ScanState.DCS_SIXEL; + } else if ((ch >= 0x40) && (ch <= 0x7E)) { + // 0x40-7E goes to DCS_PASSTHROUGH scanState = ScanState.DCS_PASSTHROUGH; } return; @@ -6487,6 +6515,48 @@ public class ECMA48 implements Runnable { return; + case DCS_SIXEL: + // 0x9C goes to GROUND + if (ch == 0x9C) { + parseSixel(); + toGround(); + } + + // 0x1B 0x5C goes to GROUND + if (ch == 0x1B) { + collect(ch); + } + if (ch == 0x5C) { + if ((collectBuffer.length() > 0) + && (collectBuffer.charAt(collectBuffer.length() - 1) == 0x1B) + ) { + parseSixel(); + toGround(); + } + } + + // 00-17, 19, 1C-1F, 20-7E --> put + if (ch <= 0x17) { + sixelParseBuffer.append(ch); + return; + } + if (ch == 0x19) { + sixelParseBuffer.append(ch); + return; + } + if ((ch >= 0x1C) && (ch <= 0x1F)) { + sixelParseBuffer.append(ch); + return; + } + if ((ch >= 0x20) && (ch <= 0x7E)) { + sixelParseBuffer.append(ch); + return; + } + + // 7F --> ignore + + return; + case SOSPMAPC_STRING: // 00-17, 19, 1C-1F, 20-7F --> ignore @@ -6573,4 +6643,121 @@ public class ECMA48 implements Runnable { return hideMousePointer; } + // ------------------------------------------------------------------------ + // Sixel support ---------------------------------------------------------- + // ------------------------------------------------------------------------ + + /** + * Set the width of a character cell in pixels. + * + * @param textWidth the width in pixels of a character cell + */ + public void setTextWidth(final int textWidth) { + this.textWidth = textWidth; + } + + /** + * Set the height of a character cell in pixels. + * + * @param textHeight the height in pixels of a character cell + */ + public void setTextHeight(final int textHeight) { + this.textHeight = textHeight; + } + + /** + * Parse a sixel string into a bitmap image, and overlay that image onto + * the text cells. + */ + private void parseSixel() { + System.err.println("parseSixel(): '" + sixelParseBuffer.toString() + + "'"); + + Sixel sixel = new Sixel(sixelParseBuffer.toString()); + BufferedImage image = sixel.getImage(); + + System.err.println("parseSixel(): image " + image); + + if (image == null) { + // Sixel data was malformed in some way, bail out. + return; + } + + /* + * Procedure: + * + * Break up the image into text cell sized pieces as a new array of + * Cells. + * + * Note original column position x0. + * + * For each cell: + * + * 1. Advance (printCharacter(' ')) for horizontal increment, or + * index (linefeed() + cursorPosition(y, x0)) for vertical + * increment. + * + * 2. Set (x, y) cell image data. + * + * 3. For the right and bottom edges: + * + * a. Render the text to pixels using Terminus font. + * + * b. Blit the image on top of the text, using alpha channel. + */ + int cellColumns = image.getWidth() / textWidth; + if (cellColumns * textWidth < image.getWidth()) { + cellColumns++; + } + int cellRows = image.getHeight() / textHeight; + if (cellRows * textHeight < image.getHeight()) { + cellRows++; + } + + // Break the image up into an array of cells. + Cell [][] cells = new Cell[cellColumns][cellRows]; + + for (int x = 0; x < cellColumns; x++) { + for (int y = 0; y < cellRows; y++) { + + int width = textWidth; + if ((x + 1) * textWidth > image.getWidth()) { + width = image.getWidth() - (x * textWidth); + } + int height = textHeight; + if ((y + 1) * textHeight > image.getHeight()) { + height = image.getHeight() - (y * textHeight); + } + + Cell cell = new Cell(); + cell.setImage(image.getSubimage(x * textWidth, + y * textHeight, width, height)); + + cells[x][y] = cell; + } + } + + int x0 = currentState.cursorX; + for (int y = 0; y < cellRows; y++) { + for (int x = 0; x < cellColumns; x++) { + printCharacter(' '); + cursorLeft(1, false); + if ((x == cellColumns - 1) || (y == cellRows - 1)) { + // TODO: render text of current cell first, then image + // over it. For now, just copy the cell. + DisplayLine line = display.get(currentState.cursorY); + line.replace(currentState.cursorX, cells[x][y]); + } else { + // Copy the image cell into the display. + DisplayLine line = display.get(currentState.cursorY); + line.replace(currentState.cursorX, cells[x][y]); + } + cursorRight(1, false); + } + linefeed(); + cursorPosition(currentState.cursorY, x0); + } + + } + } diff --git a/src/jexer/tterminal/Sixel.java b/src/jexer/tterminal/Sixel.java new file mode 100644 index 00000000..8d8429b8 --- /dev/null +++ b/src/jexer/tterminal/Sixel.java @@ -0,0 +1,523 @@ +/* + * Jexer - Java Text User Interface + * + * The MIT License (MIT) + * + * Copyright (C) 2019 Kevin Lamonte + * + * Permission is hereby granted, free of charge, to any person obtaining a + * copy of this software and associated documentation files (the "Software"), + * to deal in the Software without restriction, including without limitation + * the rights to use, copy, modify, merge, publish, distribute, sublicense, + * and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL + * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER + * DEALINGS IN THE SOFTWARE. + * + * @author Kevin Lamonte [kevin.lamonte@gmail.com] + * @version 1 + */ +package jexer.tterminal; + +import java.awt.Color; +import java.awt.Graphics2D; +import java.awt.image.BufferedImage; +import java.util.ArrayList; +import java.util.HashMap; + +/** + * Sixel parses a buffer of sixel image data into a BufferedImage. + */ +public class Sixel { + + // ------------------------------------------------------------------------ + // Constants -------------------------------------------------------------- + // ------------------------------------------------------------------------ + + /** + * Parser character scan states. + */ + private enum ScanState { + GROUND, + QUOTE, + COLOR_ENTRY, + COLOR_PARAM, + COLOR_PIXELS, + SIXEL_REPEAT, + } + + // ------------------------------------------------------------------------ + // Variables -------------------------------------------------------------- + // ------------------------------------------------------------------------ + + /** + * If true, enable debug messages. + */ + private static boolean DEBUG = true; + + /** + * Number of pixels to increment when we need more horizontal room. + */ + private static int WIDTH_INCREASE = 400; + + /** + * Number of pixels to increment when we need more vertical room. + */ + private static int HEIGHT_INCREASE = 400; + + /** + * Current scanning state. + */ + private ScanState scanState = ScanState.GROUND; + + /** + * Parameter characters being collected. + */ + private ArrayList colorParams; + + /** + * The sixel palette colors specified. + */ + private HashMap palette; + + /** + * The buffer to parse. + */ + private String buffer; + + /** + * The image being drawn to. + */ + private BufferedImage image; + + /** + * The real width of image. + */ + private int width = 0; + + /** + * The real height of image. + */ + private int height = 0; + + /** + * The repeat count. + */ + private int repeatCount = -1; + + /** + * The current drawing x position. + */ + private int x = 0; + + /** + * The current drawing color. + */ + private Color color = Color.BLACK; + + // ------------------------------------------------------------------------ + // Constructors ----------------------------------------------------------- + // ------------------------------------------------------------------------ + + /** + * Public constructor. + * + * @param buffer the sixel data to parse + */ + public Sixel(final String buffer) { + this.buffer = buffer; + colorParams = new ArrayList(); + palette = new HashMap(); + image = new BufferedImage(200, 100, BufferedImage.TYPE_INT_ARGB); + for (int i = 0; i < buffer.length(); i++) { + consume(buffer.charAt(i)); + } + } + + // ------------------------------------------------------------------------ + // Sixel ------------------------------------------------------------------ + // ------------------------------------------------------------------------ + + /** + * Get the image. + * + * @return the sixel data as an image. + */ + public BufferedImage getImage() { + if ((width > 0) && (height > 0)) { + return image.getSubimage(0, 0, width, height); + } + return null; + } + + /** + * Resize image to a new size. + * + * @param newWidth new width of image + * @param newHeight new height of image + */ + private void resizeImage(final int newWidth, final int newHeight) { + BufferedImage newImage = new BufferedImage(newWidth, newHeight, + BufferedImage.TYPE_INT_ARGB); + + Graphics2D gr = newImage.createGraphics(); + gr.drawImage(image, 0, 0, image.getWidth(), image.getHeight(), null); + gr.dispose(); + image = newImage; + } + + /** + * Clear the parameters and flags. + */ + private void toGround() { + colorParams.clear(); + scanState = ScanState.GROUND; + repeatCount = -1; + } + + /** + * Save a byte into the color parameters buffer. + * + * @param ch byte to save + */ + private void param(final byte ch) { + if (colorParams.size() == 0) { + colorParams.add(Integer.valueOf(0)); + } + Integer n = colorParams.get(colorParams.size() - 1); + if ((ch >= '0') && (ch <= '9')) { + n *= 10; + n += (ch - '0'); + colorParams.set(colorParams.size() - 1, n); + } + + if ((ch == ';') && (colorParams.size() < 16)) { + colorParams.add(Integer.valueOf(0)); + } + } + + /** + * Get a color parameter value, with a default. + * + * @param position parameter index. 0 is the first parameter. + * @param defaultValue value to use if colorParams[position] doesn't exist + * @return parameter value + */ + private int getColorParam(final int position, final int defaultValue) { + if (colorParams.size() < position + 1) { + return defaultValue; + } + return colorParams.get(position).intValue(); + } + + /** + * Get a color parameter value, clamped to within min/max. + * + * @param position parameter index. 0 is the first parameter. + * @param defaultValue value to use if colorParams[position] doesn't exist + * @param minValue minimum value inclusive + * @param maxValue maximum value inclusive + * @return parameter value + */ + private int getColorParam(final int position, final int defaultValue, + final int minValue, final int maxValue) { + + assert (minValue <= maxValue); + int value = getColorParam(position, defaultValue); + if (value < minValue) { + value = minValue; + } + if (value > maxValue) { + value = maxValue; + } + return value; + } + + /** + * Add sixel data to the image. + * + * @param ch the character of sixel data + */ + private void addSixel(final char ch) { + int n = ((int) ch - 63); + int rgb = color.getRGB(); + int rep = (repeatCount == -1 ? 1 : repeatCount); + + if (DEBUG) { + System.err.println("addSixel() rep " + rep + " char " + + Integer.toHexString(n) + " color " + color); + } + + if (x + rep > image.getWidth()) { + // Resize the image, give us another max(rep, WIDTH_INCREASE) + // pixels of horizontal length. + resizeImage(image.getWidth() + Math.max(rep, WIDTH_INCREASE), + image.getHeight()); + } + + // If nothing will be drawn, just advance x. + if (n == 0) { + x += rep; + if (x > width) { + width = x; + } + return; + } + + for (int i = 0; i < rep; i++) { + if ((n & 0x01) == 0x01) { + image.setRGB(x, height, rgb); + } + if ((n & 0x02) == 0x02) { + image.setRGB(x, height + 1, rgb); + } + if ((n & 0x04) == 0x04) { + image.setRGB(x, height + 2, rgb); + } + if ((n & 0x08) == 0x08) { + image.setRGB(x, height + 3, rgb); + } + if ((n & 0x10) == 0x10) { + image.setRGB(x, height + 4, rgb); + } + if ((n & 0x20) == 0x20) { + image.setRGB(x, height + 5, rgb); + } + x++; + if (x > width) { + width++; + assert (x == width); + } + } + } + + /** + * Process a color palette change. + */ + private void setPalette() { + int idx = getColorParam(0, 0); + + if (colorParams.size() == 1) { + Color newColor = palette.get(idx); + if (newColor != null) { + color = newColor; + } + + if (DEBUG) { + System.err.println("set color: " + color); + } + return; + } + + int type = getColorParam(1, 0); + float red = (float) (getColorParam(2, 0, 0, 100) / 100.0); + float green = (float) (getColorParam(3, 0, 0, 100) / 100.0); + float blue = (float) (getColorParam(4, 0, 0, 100) / 100.0); + + if (type == 2) { + Color newColor = new Color(red, green, blue); + palette.put(idx, newColor); + if (DEBUG) { + System.err.println("Palette color " + idx + " --> " + newColor); + } + } + } + + /** + * Run this input character through the sixel state machine. + * + * @param ch character from the remote side + */ + private void consume(char ch) { + + // DEBUG + // System.err.printf("Sixel.consume() %c STATE = %s\n", ch, scanState); + + switch (scanState) { + + case GROUND: + switch (ch) { + case '#': + scanState = ScanState.COLOR_ENTRY; + return; + case '\"': + scanState = ScanState.QUOTE; + return; + default: + break; + } + + if (ch == '!') { + // Repeat count + scanState = ScanState.SIXEL_REPEAT; + } + if (ch == '-') { + if (height + 6 < image.getHeight()) { + // Resize the image, give us another HEIGHT_INCREASE + // pixels of vertical length. + resizeImage(image.getWidth(), + image.getHeight() + HEIGHT_INCREASE); + } + height += 6; + x = 0; + } + + if (ch == '$') { + x = 0; + } + return; + + case QUOTE: + switch (ch) { + case '#': + scanState = ScanState.COLOR_ENTRY; + return; + default: + break; + } + + // Ignore everything else in the quote header. + return; + + case COLOR_ENTRY: + // Between decimal 63 (inclusive) and 189 (exclusive) --> pixels + if ((ch >= 63) && (ch < 189)) { + addSixel(ch); + return; + } + + // 30-39, 3B --> param, then switch to COLOR_PARAM + if ((ch >= '0') && (ch <= '9')) { + param((byte) ch); + scanState = ScanState.COLOR_PARAM; + } + if (ch == ';') { + param((byte) ch); + scanState = ScanState.COLOR_PARAM; + } + + if (ch == '#') { + // Next color is here, parse what we had before. + setPalette(); + toGround(); + } + + if (ch == '!') { + setPalette(); + toGround(); + + // Repeat count + scanState = ScanState.SIXEL_REPEAT; + } + if (ch == '-') { + setPalette(); + toGround(); + + if (height + 6 < image.getHeight()) { + // Resize the image, give us another HEIGHT_INCREASE + // pixels of vertical length. + resizeImage(image.getWidth(), + image.getHeight() + HEIGHT_INCREASE); + } + height += 6; + x = 0; + } + + if (ch == '$') { + setPalette(); + toGround(); + + x = 0; + } + return; + + case COLOR_PARAM: + + // Between decimal 63 (inclusive) and 189 (exclusive) --> pixels + if ((ch >= 63) && (ch < 189)) { + addSixel(ch); + return; + } + + // 30-39, 3B --> param, then switch to COLOR_PARAM + if ((ch >= '0') && (ch <= '9')) { + param((byte) ch); + } + if (ch == ';') { + param((byte) ch); + } + + if (ch == '#') { + // Next color is here, parse what we had before. + setPalette(); + toGround(); + scanState = ScanState.COLOR_ENTRY; + } + + if (ch == '!') { + setPalette(); + toGround(); + + // Repeat count + scanState = ScanState.SIXEL_REPEAT; + } + if (ch == '-') { + setPalette(); + toGround(); + + if (height + 6 < image.getHeight()) { + // Resize the image, give us another HEIGHT_INCREASE + // pixels of vertical length. + resizeImage(image.getWidth(), + image.getHeight() + HEIGHT_INCREASE); + } + height += 6; + x = 0; + } + + if (ch == '$') { + setPalette(); + toGround(); + + x = 0; + } + return; + + case SIXEL_REPEAT: + + // Between decimal 63 (inclusive) and 189 (exclusive) --> pixels + if ((ch >= 63) && (ch < 189)) { + addSixel(ch); + toGround(); + } + + if ((ch >= '0') && (ch <= '9')) { + if (repeatCount == -1) { + repeatCount = (int) (ch - '0'); + } else { + repeatCount *= 10; + repeatCount += (int) (ch - '0'); + } + } + + if (ch == '#') { + // Next color. + toGround(); + scanState = ScanState.COLOR_ENTRY; + } + + return; + } + + } + +} -- 2.27.0