X-Git-Url: http://git.nikiroo.be/?a=blobdiff_plain;f=src%2Fjexer%2Fbackend%2FECMA48Terminal.java;h=1dc3957d75b534deec2b318a9c2bd501db8d7f5f;hb=a69ed767c9c07cf35cf1c5f7821fc009cfe79cd2;hp=84f6528c5a348832642bc21315dea8b1ddbb5bbc;hpb=d625990deaa2c24624adc9fbd3fcab58891f5aef;p=nikiroo-utils.git diff --git a/src/jexer/backend/ECMA48Terminal.java b/src/jexer/backend/ECMA48Terminal.java index 84f6528..1dc3957 100644 --- a/src/jexer/backend/ECMA48Terminal.java +++ b/src/jexer/backend/ECMA48Terminal.java @@ -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"), @@ -28,6 +28,7 @@ */ package jexer.backend; +import java.awt.image.BufferedImage; import java.io.BufferedReader; import java.io.FileDescriptor; import java.io.FileInputStream; @@ -40,9 +41,13 @@ import java.io.PrintWriter; import java.io.Reader; import java.io.UnsupportedEncodingException; import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; import java.util.List; import java.util.LinkedList; +import java.util.Map; +import jexer.TImage; import jexer.bits.Cell; import jexer.bits.CellAttributes; import jexer.bits.Color; @@ -76,6 +81,12 @@ public class ECMA48Terminal extends LogicalScreen MOUSE_SGR, } + /** + * Number of colors in the sixel palette. Xterm 335 defines the max as + * 1024. + */ + private static final int MAX_COLOR_REGISTERS = 1024; + // ------------------------------------------------------------------------ // Variables -------------------------------------------------------------- // ------------------------------------------------------------------------ @@ -162,6 +173,31 @@ public class ECMA48Terminal extends LogicalScreen */ private TResizeEvent windowResize = null; + /** + * Window width in pixels. Used for sixel support. + */ + private int widthPixels = 640; + + /** + * Window height in pixels. Used for sixel support. + */ + private int heightPixels = 400; + + /** + * If true, emit image data via sixel. + */ + private boolean sixel = true; + + /** + * The sixel palette handler. + */ + private SixelPalette palette = null; + + /** + * The sixel post-rendered string cache. + */ + private SixelCache sixelCache = null; + /** * If true, then we changed System.in and need to change it back. */ @@ -194,6 +230,753 @@ public class ECMA48Terminal extends LogicalScreen */ private Object listener; + /** + * SixelPalette is used to manage the conversion of images between 24-bit + * RGB color and a palette of MAX_COLOR_REGISTERS colors. + */ + private class SixelPalette { + + /** + * Color palette for sixel output, sorted low to high. + */ + private List rgbColors = new ArrayList(); + + /** + * Map of color palette index for sixel output, from the order it was + * generated by makePalette() to rgbColors. + */ + private int [] rgbSortedIndex = new int[MAX_COLOR_REGISTERS]; + + /** + * The color palette, organized by hue, saturation, and luminance. + * This is used for a fast color match. + */ + private ArrayList>> hslColors; + + /** + * Number of bits for hue. + */ + private int hueBits = -1; + + /** + * Number of bits for saturation. + */ + private int satBits = -1; + + /** + * Number of bits for luminance. + */ + private int lumBits = -1; + + /** + * Step size for hue bins. + */ + private int hueStep = -1; + + /** + * Step size for saturation bins. + */ + private int satStep = -1; + + /** + * Cached RGB to HSL result. + */ + private int hsl[] = new int[3]; + + /** + * ColorIdx records a RGB color and its palette index. + */ + private class ColorIdx { + /** + * The 24-bit RGB color. + */ + public int color; + + /** + * The palette index for this color. + */ + public int index; + + /** + * Public constructor. + * + * @param color the 24-bit RGB color + * @param index the palette index for this color + */ + public ColorIdx(final int color, final int index) { + this.color = color; + this.index = index; + } + } + + /** + * Public constructor. + */ + public SixelPalette() { + makePalette(); + } + + /** + * Find the nearest match for a color in the palette. + * + * @param color the RGB color + * @return the index in rgbColors that is closest to color + */ + public int matchColor(final int color) { + + assert (color >= 0); + + /* + * matchColor() is a critical performance bottleneck. To make it + * decent, we do the following: + * + * 1. Find the nearest two hues that bracket this color. + * + * 2. Find the nearest two saturations that bracket this color. + * + * 3. Iterate within these four bands of luminance values, + * returning the closest color by Euclidean distance. + * + * This strategy reduces the search space by about 97%. + */ + int red = (color >>> 16) & 0xFF; + int green = (color >>> 8) & 0xFF; + int blue = color & 0xFF; + + rgbToHsl(red, green, blue, hsl); + int hue = hsl[0]; + int sat = hsl[1]; + int lum = hsl[2]; + // System.err.printf("%d %d %d\n", hue, sat, lum); + + double diff = Double.MAX_VALUE; + int idx = -1; + + int hue1 = hue / (360/hueStep); + int hue2 = hue1 + 1; + if (hue1 >= hslColors.size() - 1) { + // Bracket pure red from above. + hue1 = hslColors.size() - 1; + hue2 = 0; + } else if (hue1 == 0) { + // Bracket pure red from below. + hue2 = hslColors.size() - 1; + } + + for (int hI = hue1; hI != -1;) { + ArrayList> sats = hslColors.get(hI); + if (hI == hue1) { + hI = hue2; + } else if (hI == hue2) { + hI = -1; + } + + int sMin = (sat / satStep) - 1; + int sMax = sMin + 1; + if (sMin < 0) { + sMin = 0; + sMax = 1; + } else if (sMin == sats.size() - 1) { + sMax = sMin; + sMin--; + } + assert (sMin >= 0); + assert (sMax - sMin == 1); + + // int sMin = 0; + // int sMax = sats.size() - 1; + + for (int sI = sMin; sI <= sMax; sI++) { + ArrayList lums = sats.get(sI); + + // True 3D colorspace match for the remaining values + for (ColorIdx c: lums) { + int rgbColor = c.color; + double newDiff = 0; + int red2 = (rgbColor >>> 16) & 0xFF; + int green2 = (rgbColor >>> 8) & 0xFF; + int blue2 = rgbColor & 0xFF; + newDiff += Math.pow(red2 - red, 2); + newDiff += Math.pow(green2 - green, 2); + newDiff += Math.pow(blue2 - blue, 2); + if (newDiff < diff) { + idx = rgbSortedIndex[c.index]; + diff = newDiff; + } + } + } + } + + if (((red * red) + (green * green) + (blue * blue)) < diff) { + // Black is a closer match. + idx = 0; + } else if ((((255 - red) * (255 - red)) + + ((255 - green) * (255 - green)) + + ((255 - blue) * (255 - blue))) < diff) { + + // White is a closer match. + idx = MAX_COLOR_REGISTERS - 1; + } + assert (idx != -1); + return idx; + } + + /** + * Clamp an int value to [0, 255]. + * + * @param x the int value + * @return an int between 0 and 255. + */ + private int clamp(final int x) { + if (x < 0) { + return 0; + } + if (x > 255) { + return 255; + } + return x; + } + + /** + * Dither an image to a MAX_COLOR_REGISTERS palette. The dithered + * image cells will contain indexes into the palette. + * + * @param image the image to dither + * @return the dithered image. Every pixel is an index into the + * palette. + */ + public BufferedImage ditherImage(final BufferedImage image) { + + BufferedImage ditheredImage = new BufferedImage(image.getWidth(), + image.getHeight(), BufferedImage.TYPE_INT_ARGB); + + int [] rgbArray = image.getRGB(0, 0, image.getWidth(), + image.getHeight(), null, 0, image.getWidth()); + ditheredImage.setRGB(0, 0, image.getWidth(), image.getHeight(), + rgbArray, 0, image.getWidth()); + + for (int imageY = 0; imageY < image.getHeight(); imageY++) { + for (int imageX = 0; imageX < image.getWidth(); imageX++) { + int oldPixel = ditheredImage.getRGB(imageX, + imageY) & 0xFFFFFF; + int colorIdx = matchColor(oldPixel); + assert (colorIdx >= 0); + assert (colorIdx < MAX_COLOR_REGISTERS); + int newPixel = rgbColors.get(colorIdx); + ditheredImage.setRGB(imageX, imageY, colorIdx); + + int oldRed = (oldPixel >>> 16) & 0xFF; + int oldGreen = (oldPixel >>> 8) & 0xFF; + int oldBlue = oldPixel & 0xFF; + + int newRed = (newPixel >>> 16) & 0xFF; + int newGreen = (newPixel >>> 8) & 0xFF; + int newBlue = newPixel & 0xFF; + + int redError = (oldRed - newRed) / 16; + int greenError = (oldGreen - newGreen) / 16; + int blueError = (oldBlue - newBlue) / 16; + + int red, green, blue; + if (imageX < image.getWidth() - 1) { + int pXpY = ditheredImage.getRGB(imageX + 1, imageY); + red = (int) ((pXpY >>> 16) & 0xFF) + (7 * redError); + green = (int) ((pXpY >>> 8) & 0xFF) + (7 * greenError); + blue = (int) ( pXpY & 0xFF) + (7 * blueError); + red = clamp(red); + green = clamp(green); + blue = clamp(blue); + pXpY = ((red & 0xFF) << 16); + pXpY |= ((green & 0xFF) << 8) | (blue & 0xFF); + ditheredImage.setRGB(imageX + 1, imageY, pXpY); + + if (imageY < image.getHeight() - 1) { + int pXpYp = ditheredImage.getRGB(imageX + 1, + imageY + 1); + red = (int) ((pXpYp >>> 16) & 0xFF) + redError; + green = (int) ((pXpYp >>> 8) & 0xFF) + greenError; + blue = (int) ( pXpYp & 0xFF) + blueError; + red = clamp(red); + green = clamp(green); + blue = clamp(blue); + pXpYp = ((red & 0xFF) << 16); + pXpYp |= ((green & 0xFF) << 8) | (blue & 0xFF); + ditheredImage.setRGB(imageX + 1, imageY + 1, pXpYp); + } + } else if (imageY < image.getHeight() - 1) { + int pXmYp = ditheredImage.getRGB(imageX - 1, + imageY + 1); + int pXYp = ditheredImage.getRGB(imageX, + imageY + 1); + + red = (int) ((pXmYp >>> 16) & 0xFF) + (3 * redError); + green = (int) ((pXmYp >>> 8) & 0xFF) + (3 * greenError); + blue = (int) ( pXmYp & 0xFF) + (3 * blueError); + red = clamp(red); + green = clamp(green); + blue = clamp(blue); + pXmYp = ((red & 0xFF) << 16); + pXmYp |= ((green & 0xFF) << 8) | (blue & 0xFF); + ditheredImage.setRGB(imageX - 1, imageY + 1, pXmYp); + + red = (int) ((pXYp >>> 16) & 0xFF) + (5 * redError); + green = (int) ((pXYp >>> 8) & 0xFF) + (5 * greenError); + blue = (int) ( pXYp & 0xFF) + (5 * blueError); + red = clamp(red); + green = clamp(green); + blue = clamp(blue); + pXYp = ((red & 0xFF) << 16); + pXYp |= ((green & 0xFF) << 8) | (blue & 0xFF); + ditheredImage.setRGB(imageX, imageY + 1, pXYp); + } + } // for (int imageY = 0; imageY < image.getHeight(); imageY++) + } // for (int imageX = 0; imageX < image.getWidth(); imageX++) + + return ditheredImage; + } + + /** + * Convert an RGB color to HSL. + * + * @param red red color, between 0 and 255 + * @param green green color, between 0 and 255 + * @param blue blue color, between 0 and 255 + * @param hsl the hsl color as [hue, saturation, luminance] + */ + private void rgbToHsl(final int red, final int green, + final int blue, final int [] hsl) { + + assert ((red >= 0) && (red <= 255)); + assert ((green >= 0) && (green <= 255)); + assert ((blue >= 0) && (blue <= 255)); + + double R = red / 255.0; + double G = green / 255.0; + double B = blue / 255.0; + boolean Rmax = false; + boolean Gmax = false; + boolean Bmax = false; + double min = (R < G ? R : G); + min = (min < B ? min : B); + double max = 0; + if ((R >= G) && (R >= B)) { + max = R; + Rmax = true; + } else if ((G >= R) && (G >= B)) { + max = G; + Gmax = true; + } else if ((B >= G) && (B >= R)) { + max = B; + Bmax = true; + } + + double L = (min + max) / 2.0; + double H = 0.0; + double S = 0.0; + if (min != max) { + if (L < 0.5) { + S = (max - min) / (max + min); + } else { + S = (max - min) / (2.0 - max - min); + } + } + if (Rmax) { + assert (Gmax == false); + assert (Bmax == false); + H = (G - B) / (max - min); + } else if (Gmax) { + assert (Rmax == false); + assert (Bmax == false); + H = 2.0 + (B - R) / (max - min); + } else if (Bmax) { + assert (Rmax == false); + assert (Gmax == false); + H = 4.0 + (R - G) / (max - min); + } + if (H < 0.0) { + H += 6.0; + } + hsl[0] = (int) (H * 60.0); + hsl[1] = (int) (S * 100.0); + hsl[2] = (int) (L * 100.0); + + assert ((hsl[0] >= 0) && (hsl[0] <= 360)); + assert ((hsl[1] >= 0) && (hsl[1] <= 100)); + assert ((hsl[2] >= 0) && (hsl[2] <= 100)); + } + + /** + * Convert a HSL color to RGB. + * + * @param hue hue, between 0 and 359 + * @param sat saturation, between 0 and 100 + * @param lum luminance, between 0 and 100 + * @return the rgb color as 0x00RRGGBB + */ + private int hslToRgb(final int hue, final int sat, final int lum) { + assert ((hue >= 0) && (hue <= 360)); + assert ((sat >= 0) && (sat <= 100)); + assert ((lum >= 0) && (lum <= 100)); + + double S = sat / 100.0; + double L = lum / 100.0; + double C = (1.0 - Math.abs((2.0 * L) - 1.0)) * S; + double Hp = hue / 60.0; + double X = C * (1.0 - Math.abs((Hp % 2) - 1.0)); + double Rp = 0.0; + double Gp = 0.0; + double Bp = 0.0; + if (Hp <= 1.0) { + Rp = C; + Gp = X; + } else if (Hp <= 2.0) { + Rp = X; + Gp = C; + } else if (Hp <= 3.0) { + Gp = C; + Bp = X; + } else if (Hp <= 4.0) { + Gp = X; + Bp = C; + } else if (Hp <= 5.0) { + Rp = X; + Bp = C; + } else if (Hp <= 6.0) { + Rp = C; + Bp = X; + } + double m = L - (C / 2.0); + int red = ((int) ((Rp + m) * 255.0)) << 16; + int green = ((int) ((Gp + m) * 255.0)) << 8; + int blue = (int) ((Bp + m) * 255.0); + + return (red | green | blue); + } + + /** + * Create the sixel palette. + */ + private void makePalette() { + // Generate the sixel palette. Because we have no idea at this + // layer which image(s) will be shown, we have to use a common + // palette with MAX_COLOR_REGISTERS colors for everything, and + // map the BufferedImage colors to their nearest neighbor in RGB + // space. + + // We build a palette using the Hue-Saturation-Luminence model, + // with 5+ bits for Hue, 2+ bits for Saturation, and 1+ bit for + // Luminance. We convert these colors to 24-bit RGB, sort them + // ascending, and steal the first index for pure black and the + // last for pure white. The 8-bit final palette favors bright + // colors, somewhere between pastel and classic television + // technicolor. 9- and 10-bit palettes are more uniform. + + // Default at 256 colors. + hueBits = 5; + satBits = 2; + lumBits = 1; + + assert (MAX_COLOR_REGISTERS >= 256); + assert ((MAX_COLOR_REGISTERS == 256) + || (MAX_COLOR_REGISTERS == 512) + || (MAX_COLOR_REGISTERS == 1024) + || (MAX_COLOR_REGISTERS == 2048)); + + switch (MAX_COLOR_REGISTERS) { + case 512: + hueBits = 5; + satBits = 2; + lumBits = 2; + break; + case 1024: + hueBits = 5; + satBits = 2; + lumBits = 3; + break; + case 2048: + hueBits = 5; + satBits = 3; + lumBits = 3; + break; + } + hueStep = (int) (Math.pow(2, hueBits)); + satStep = (int) (100 / Math.pow(2, satBits)); + // 1 bit for luminance: 40 and 70. + int lumBegin = 40; + int lumStep = 30; + switch (lumBits) { + case 2: + // 2 bits: 20, 40, 60, 80 + lumBegin = 20; + lumStep = 20; + break; + case 3: + // 3 bits: 8, 20, 32, 44, 56, 68, 80, 92 + lumBegin = 8; + lumStep = 12; + break; + } + + // System.err.printf("\n"); + // Hue is evenly spaced around the wheel. + hslColors = new ArrayList>>(); + + final boolean DEBUG = false; + ArrayList rawRgbList = new ArrayList(); + + for (int hue = 0; hue < (360 - (360 % hueStep)); + hue += (360/hueStep)) { + + ArrayList> satList = null; + satList = new ArrayList>(); + hslColors.add(satList); + + // Saturation is linearly spaced between pastel and pure. + for (int sat = satStep; sat <= 100; sat += satStep) { + + ArrayList lumList = new ArrayList(); + satList.add(lumList); + + // Luminance brackets the pure color, but leaning toward + // lighter. + for (int lum = lumBegin; lum < 100; lum += lumStep) { + /* + System.err.printf("=\n"); + */ + int rgbColor = hslToRgb(hue, sat, lum); + rgbColors.add(rgbColor); + ColorIdx colorIdx = new ColorIdx(rgbColor, + rgbColors.size() - 1); + lumList.add(colorIdx); + + rawRgbList.add(rgbColor); + if (DEBUG) { + int red = (rgbColor >>> 16) & 0xFF; + int green = (rgbColor >>> 8) & 0xFF; + int blue = rgbColor & 0xFF; + int [] backToHsl = new int[3]; + rgbToHsl(red, green, blue, backToHsl); + System.err.printf("%d [%d] %d [%d] %d [%d]\n", + hue, backToHsl[0], sat, backToHsl[1], + lum, backToHsl[2]); + } + } + } + } + // System.err.printf("\n\n"); + + assert (rgbColors.size() == MAX_COLOR_REGISTERS); + + /* + * We need to sort rgbColors, so that toSixel() can know where + * BLACK and WHITE are in it. But we also need to be able to + * find the sorted values using the old unsorted indexes. So we + * will sort it, put all the indexes into a HashMap, and then + * build rgbSortedIndex[]. + */ + Collections.sort(rgbColors); + HashMap rgbColorIndices = null; + rgbColorIndices = new HashMap(); + for (int i = 0; i < MAX_COLOR_REGISTERS; i++) { + rgbColorIndices.put(rgbColors.get(i), i); + } + for (int i = 0; i < MAX_COLOR_REGISTERS; i++) { + int rawColor = rawRgbList.get(i); + rgbSortedIndex[i] = rgbColorIndices.get(rawColor); + } + if (DEBUG) { + for (int i = 0; i < MAX_COLOR_REGISTERS; i++) { + assert (rawRgbList != null); + int idx = rgbSortedIndex[i]; + int rgbColor = rgbColors.get(idx); + if ((idx != 0) && (idx != MAX_COLOR_REGISTERS - 1)) { + /* + System.err.printf("%d %06x --> %d %06x\n", + i, rawRgbList.get(i), idx, rgbColors.get(idx)); + */ + assert (rgbColor == rawRgbList.get(i)); + } + } + } + + // Set the dimmest color as true black, and the brightest as true + // white. + rgbColors.set(0, 0); + rgbColors.set(MAX_COLOR_REGISTERS - 1, 0xFFFFFF); + + /* + System.err.printf("\n"); + for (Integer rgb: rgbColors) { + System.err.printf("=\n"); + } + System.err.printf("\n\n"); + */ + + } + + /** + * Emit the sixel palette. + * + * @param sb the StringBuilder to append to + * @param used array of booleans set to true for each color actually + * used in this cell, or null to emit the entire palette + * @return the string to emit to an ANSI / ECMA-style terminal + */ + public String emitPalette(final StringBuilder sb, + final boolean [] used) { + + for (int i = 0; i < MAX_COLOR_REGISTERS; i++) { + if (((used != null) && (used[i] == true)) || (used == null)) { + int rgbColor = rgbColors.get(i); + sb.append(String.format("#%d;2;%d;%d;%d", i, + ((rgbColor >>> 16) & 0xFF) * 100 / 255, + ((rgbColor >>> 8) & 0xFF) * 100 / 255, + ( rgbColor & 0xFF) * 100 / 255)); + } + } + return sb.toString(); + } + } + + /** + * SixelCache is a least-recently-used cache that hangs on to the + * post-rendered sixel string for a particular set of cells. + */ + private class SixelCache { + + /** + * Maximum size of the cache. + */ + private int maxSize = 100; + + /** + * The entries stored in the cache. + */ + private HashMap cache = null; + + /** + * CacheEntry is one entry in the cache. + */ + private class CacheEntry { + /** + * The cache key. + */ + public String key; + + /** + * The cache data. + */ + public String data; + + /** + * The last time this entry was used. + */ + public long millis = 0; + + /** + * Public constructor. + * + * @param key the cache entry key + * @param data the cache entry data + */ + public CacheEntry(final String key, final String data) { + this.key = key; + this.data = data; + this.millis = System.currentTimeMillis(); + } + } + + /** + * Public constructor. + * + * @param maxSize the maximum size of the cache + */ + public SixelCache(final int maxSize) { + this.maxSize = maxSize; + cache = new HashMap(); + } + + /** + * Make a unique key for a list of cells. + * + * @param cells the cells + * @return the key + */ + private String makeKey(final ArrayList cells) { + StringBuilder sb = new StringBuilder(); + for (Cell cell: cells) { + sb.append(cell.hashCode()); + } + return sb.toString(); + } + + /** + * Get an entry from the cache. + * + * @param cells the list of cells that are the cache key + * @return the sixel string representing these cells, or null if this + * list of cells is not in the cache + */ + public String get(final ArrayList cells) { + CacheEntry entry = cache.get(makeKey(cells)); + if (entry == null) { + return null; + } + entry.millis = System.currentTimeMillis(); + return entry.data; + } + + /** + * Put an entry into the cache. + * + * @param cells the list of cells that are the cache key + * @param data the sixel string representing these cells + */ + public void put(final ArrayList cells, final String data) { + String key = makeKey(cells); + + // System.err.println("put() " + key + " size " + cache.size()); + + assert (!cache.containsKey(key)); + + assert (cache.size() <= maxSize); + if (cache.size() == maxSize) { + // Cache is at limit, evict oldest entry. + long oldestTime = Long.MAX_VALUE; + String keyToRemove = null; + for (CacheEntry entry: cache.values()) { + if ((entry.millis < oldestTime) || (keyToRemove == null)) { + keyToRemove = entry.key; + oldestTime = entry.millis; + } + } + /* + System.err.println("put() remove key = " + keyToRemove + + " size " + cache.size()); + */ + assert (keyToRemove != null); + cache.remove(keyToRemove); + /* + System.err.println("put() removed, size " + cache.size()); + */ + } + assert (cache.size() <= maxSize); + CacheEntry entry = new CacheEntry(key, data); + assert (key.equals(entry.key)); + cache.put(key, entry); + /* + System.err.println("put() added key " + key + " " + + " size " + cache.size()); + */ + } + + } + // ------------------------------------------------------------------------ // Constructors ----------------------------------------------------------- // ------------------------------------------------------------------------ @@ -286,6 +1069,9 @@ public class ECMA48Terminal extends LogicalScreen "UTF-8")); } + // Request xterm report window dimensions in pixels + this.output.printf("%s", xtermReportWindowPixelDimensions()); + // Enable mouse reporting and metaSendsEscape this.output.printf("%s%s", mouse(true), xtermMetaSendsEscape(true)); this.output.flush(); @@ -299,7 +1085,7 @@ public class ECMA48Terminal extends LogicalScreen windowResize = new TResizeEvent(TResizeEvent.Type.SCREEN, sessionInfo.getWindowWidth(), sessionInfo.getWindowHeight()); - // Permit RGB colors only if externally requested + // Permit RGB colors only if externally requested. if (System.getProperty("jexer.ECMA48.rgbColor") != null) { if (System.getProperty("jexer.ECMA48.rgbColor").equals("true")) { doRgbColor = true; @@ -308,6 +1094,15 @@ public class ECMA48Terminal extends LogicalScreen } } + // Pull the system properties for sixel output. + if (System.getProperty("jexer.ECMA48.sixel") != null) { + if (System.getProperty("jexer.ECMA48.sixel").equals("true")) { + sixel = true; + } else { + sixel = false; + } + } + // Spin up the input reader eventQueue = new LinkedList(); readerThread = new Thread(this); @@ -376,6 +1171,9 @@ public class ECMA48Terminal extends LogicalScreen this.output = writer; + // Request xterm report window dimensions in pixels + this.output.printf("%s", xtermReportWindowPixelDimensions()); + // Enable mouse reporting and metaSendsEscape this.output.printf("%s%s", mouse(true), xtermMetaSendsEscape(true)); this.output.flush(); @@ -398,6 +1196,15 @@ public class ECMA48Terminal extends LogicalScreen } } + // Pull the system properties for sixel output. + if (System.getProperty("jexer.ECMA48.sixel") != null) { + if (System.getProperty("jexer.ECMA48.sixel").equals("true")) { + sixel = true; + } else { + sixel = false; + } + } + // Spin up the input reader eventQueue = new LinkedList(); readerThread = new Thread(this); @@ -445,19 +1252,21 @@ public class ECMA48Terminal extends LogicalScreen */ @Override public void flushPhysical() { - String result = flushString(); + StringBuilder sb = new StringBuilder(); if ((cursorVisible) && (cursorY >= 0) && (cursorX >= 0) && (cursorY <= height - 1) && (cursorX <= width - 1) ) { - result += cursor(true); - result += gotoXY(cursorX, cursorY); + flushString(sb); + sb.append(cursor(true)); + sb.append(gotoXY(cursorX, cursorY)); } else { - result += cursor(false); + sb.append(cursor(false)); + flushString(sb); } - output.write(result); + output.write(sb.toString()); flush(); } @@ -504,7 +1313,9 @@ public class ECMA48Terminal extends LogicalScreen try { readerThread.join(); } catch (InterruptedException e) { - e.printStackTrace(); + if (debugToStderr) { + e.printStackTrace(); + } } // Disable mouse reporting and show cursor. Defensive null check @@ -521,17 +1332,17 @@ public class ECMA48Terminal extends LogicalScreen } else { // Shut down the streams, this should wake up the reader thread // and make it exit. - try { - if (input != null) { + if (input != null) { + try { input.close(); - input = null; - } - if (output != null) { - output.close(); - output = null; + } catch (IOException e) { + // SQUASH } - } catch (IOException e) { - e.printStackTrace(); + input = null; + } + if (output != null) { + output.close(); + output = null; } } } @@ -641,6 +1452,24 @@ public class ECMA48Terminal extends LogicalScreen // ECMA48Terminal --------------------------------------------------------- // ------------------------------------------------------------------------ + /** + * Get the width of a character cell in pixels. + * + * @return the width in pixels of a character cell + */ + public int getTextWidth() { + return (widthPixels / sessionInfo.getWindowWidth()); + } + + /** + * Get the height of a character cell in pixels. + * + * @return the height in pixels of a character cell + */ + public int getTextHeight() { + return (heightPixels / sessionInfo.getWindowHeight()); + } + /** * Getter for sessionInfo. * @@ -713,7 +1542,9 @@ public class ECMA48Terminal extends LogicalScreen process.waitFor(); break; } catch (InterruptedException e) { - e.printStackTrace(); + if (debugToStderr) { + e.printStackTrace(); + } } } int rc = process.exitValue(); @@ -756,6 +1587,8 @@ public class ECMA48Terminal extends LogicalScreen // DEBUG // reallyCleared = true; + boolean hasImage = false; + for (int x = 0; x < width; x++) { Cell lCell = logical[x][y]; Cell pCell = physical[x][y]; @@ -798,6 +1631,25 @@ public class ECMA48Terminal extends LogicalScreen return; } + // Image cell: bypass the rest of the loop, it is not + // rendered here. + if (lCell.isImage()) { + hasImage = true; + + // Save the last rendered cell + lastX = x; + + // Physical is always updated + physical[x][y].setTo(lCell); + continue; + } + + assert (!lCell.isImage()); + if (hasImage) { + hasImage = false; + sb.append(gotoXY(x, y)); + } + // Now emit only the modified attributes if ((lCell.getForeColor() != lastAttr.getForeColor()) && (lCell.getBackColor() != lastAttr.getBackColor()) @@ -970,18 +1822,70 @@ public class ECMA48Terminal extends LogicalScreen * Render the screen to a string that can be emitted to something that * knows how to process ECMA-48/ANSI X3.64 escape sequences. * + * @param sb StringBuilder to write escape sequences to * @return escape sequences string that provides the updates to the * physical screen */ - private String flushString() { + private String flushString(final StringBuilder sb) { CellAttributes attr = null; - StringBuilder sb = new StringBuilder(); if (reallyCleared) { attr = new CellAttributes(); sb.append(clearAll()); } + /* + * For sixel support, draw all of the sixel output first, and then + * draw everything else afterwards. This works OK, but performance + * is still a drag on larger pictures. + */ + for (int y = 0; y < height; y++) { + for (int x = 0; x < width; x++) { + // If physical had non-image data that is now image data, the + // entire row must be redrawn. + Cell lCell = logical[x][y]; + Cell pCell = physical[x][y]; + if (lCell.isImage() && !pCell.isImage()) { + unsetImageRow(y); + break; + } + } + } + for (int y = 0; y < height; y++) { + for (int x = 0; x < width; x++) { + Cell lCell = logical[x][y]; + Cell pCell = physical[x][y]; + + if (!lCell.isImage()) { + continue; + } + + int left = x; + int right = x; + while ((right < width) + && (logical[right][y].isImage()) + && (!logical[right][y].equals(physical[right][y]) + || reallyCleared) + ) { + right++; + } + ArrayList cellsToDraw = new ArrayList(); + for (int i = 0; i < (right - x); i++) { + assert (logical[x + i][y].isImage()); + cellsToDraw.add(logical[x + i][y]); + + // Physical is always updated. + physical[x + i][y].setTo(lCell); + } + if (cellsToDraw.size() > 0) { + sb.append(toSixel(x, y, cellsToDraw)); + } + + x = right; + } + } + + // Draw the text part now. for (int y = 0; y < height; y++) { flushLine(y, sb, attr); } @@ -1335,6 +2239,10 @@ public class ECMA48Terminal extends LogicalScreen newWidth + " x " + newHeight); } + // Request xterm report window dimensions in pixels again. + this.output.printf("%s", xtermReportWindowPixelDimensions()); + this.output.flush(); + TResizeEvent event = new TResizeEvent(TResizeEvent.Type.SCREEN, newWidth, newHeight); windowResize = new TResizeEvent(TResizeEvent.Type.SCREEN, @@ -1697,6 +2605,31 @@ public class ECMA48Terminal extends LogicalScreen events.add(new TKeypressEvent(kbEnd, alt, ctrl, shift)); resetParser(); return; + case 't': + // windowOps + if ((params.size() > 2) && (params.get(0).equals("4"))) { + if (debugToStderr) { + System.err.printf("windowOp pixels: " + + "height %s width %s\n", + params.get(1), params.get(2)); + } + try { + widthPixels = Integer.parseInt(params.get(2)); + heightPixels = Integer.parseInt(params.get(1)); + } catch (NumberFormatException e) { + if (debugToStderr) { + e.printStackTrace(); + } + } + if (widthPixels <= 0) { + widthPixels = 640; + } + if (heightPixels <= 0) { + heightPixels = 400; + } + } + resetParser(); + return; default: break; } @@ -1723,6 +2656,15 @@ public class ECMA48Terminal extends LogicalScreen return; } + /** + * Request (u)xterm to report the current window size dimensions. + * + * @return the string to emit to xterm + */ + private String xtermReportWindowPixelDimensions() { + return "\033[14t"; + } + /** * Tell (u)xterm that we want alt- keystrokes to send escape + character * rather than set the 8th bit. Anyone who wants UTF8 should want this @@ -1748,6 +2690,264 @@ public class ECMA48Terminal extends LogicalScreen return "\033]2;" + title + "\007"; } + // ------------------------------------------------------------------------ + // Sixel output support --------------------------------------------------- + // ------------------------------------------------------------------------ + + /** + * Start a sixel string for display one row's worth of bitmap data. + * + * @param x column coordinate. 0 is the left-most column. + * @param y row coordinate. 0 is the top-most row. + * @return the string to emit to an ANSI / ECMA-style terminal + */ + private String startSixel(final int x, final int y) { + StringBuilder sb = new StringBuilder(); + + assert (sixel == true); + + // Place the cursor + sb.append(gotoXY(x, y)); + + // DCS + sb.append("\033Pq"); + + if (palette == null) { + palette = new SixelPalette(); + } + + return sb.toString(); + } + + /** + * End a sixel string for display one row's worth of bitmap data. + * + * @return the string to emit to an ANSI / ECMA-style terminal + */ + private String endSixel() { + assert (sixel == true); + + // ST + return ("\033\\"); + } + + /** + * Create a sixel string representing a row of several cells containing + * bitmap data. + * + * @param x column coordinate. 0 is the left-most column. + * @param y row coordinate. 0 is the top-most row. + * @param cells the cells containing the bitmap data + * @return the string to emit to an ANSI / ECMA-style terminal + */ + private String toSixel(final int x, final int y, + final ArrayList cells) { + + StringBuilder sb = new StringBuilder(); + + assert (sixel == true); + assert (cells != null); + assert (cells.size() > 0); + assert (cells.get(0).getImage() != null); + + if (sixelCache == null) { + sixelCache = new SixelCache(height * 10); + } + + // Save and get rows to/from the cache that do NOT have inverted + // cells. + boolean saveInCache = true; + for (Cell cell: cells) { + if (cell.isInvertedImage()) { + saveInCache = false; + } + } + if (saveInCache) { + String cachedResult = sixelCache.get(cells); + if (cachedResult != null) { + // System.err.println("CACHE HIT"); + sb.append(startSixel(x, y)); + sb.append(cachedResult); + sb.append(endSixel()); + return sb.toString(); + } + // System.err.println("CACHE MISS"); + } + + int imageWidth = cells.get(0).getImage().getWidth(); + int imageHeight = cells.get(0).getImage().getHeight(); + + // cells.get(x).getImage() has a dithered bitmap containing indexes + // into the color palette. Piece these together into one larger + // image for final rendering. + int totalWidth = 0; + int fullWidth = cells.size() * getTextWidth(); + int fullHeight = getTextHeight(); + for (int i = 0; i < cells.size(); i++) { + totalWidth += cells.get(i).getImage().getWidth(); + } + + BufferedImage image = new BufferedImage(fullWidth, + fullHeight, BufferedImage.TYPE_INT_ARGB); + + int [] rgbArray; + for (int i = 0; i < cells.size() - 1; i++) { + if (cells.get(i).isInvertedImage()) { + rgbArray = new int[imageWidth * imageHeight]; + for (int j = 0; j < rgbArray.length; j++) { + rgbArray[j] = 0xFFFFFF; + } + } else { + rgbArray = cells.get(i).getImage().getRGB(0, 0, + imageWidth, imageHeight, null, 0, imageWidth); + } + image.setRGB(i * imageWidth, 0, imageWidth, imageHeight, + rgbArray, 0, imageWidth); + if (imageHeight < 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 (cells.get(cells.size() - 1).isInvertedImage()) { + rgbArray = new int[totalWidth * imageHeight]; + for (int j = 0; j < rgbArray.length; j++) { + rgbArray[j] = 0xFFFFFF; + } + } else { + rgbArray = cells.get(cells.size() - 1).getImage().getRGB(0, 0, + totalWidth, imageHeight, null, 0, totalWidth); + } + image.setRGB((cells.size() - 1) * imageWidth, 0, totalWidth, + imageHeight, rgbArray, 0, totalWidth); + + if (totalWidth < getTextWidth()) { + int backgroundColor = cells.get(cells.size() - 1).getBackground().getRGB(); + + for (int imageX = image.getWidth() - totalWidth; + imageX < image.getWidth(); imageX++) { + + for (int imageY = 0; imageY < fullHeight; imageY++) { + image.setRGB(imageX, imageY, backgroundColor); + } + } + } + + // Dither the image. It is ok to lose the original here. + if (palette == null) { + palette = new SixelPalette(); + } + image = palette.ditherImage(image); + + // Emit the palette, but only for the colors actually used by these + // cells. + boolean [] usedColors = new boolean[MAX_COLOR_REGISTERS]; + for (int imageX = 0; imageX < image.getWidth(); imageX++) { + for (int imageY = 0; imageY < image.getHeight(); imageY++) { + usedColors[image.getRGB(imageX, imageY)] = true; + } + } + palette.emitPalette(sb, usedColors); + + // Render the entire row of cells. + for (int currentRow = 0; currentRow < fullHeight; currentRow += 6) { + int [][] sixels = new int[image.getWidth()][6]; + + // See which colors are actually used in this band of sixels. + for (int imageX = 0; imageX < image.getWidth(); imageX++) { + for (int imageY = 0; + (imageY < 6) && (imageY + currentRow < fullHeight); + imageY++) { + + int colorIdx = image.getRGB(imageX, imageY + currentRow); + assert (colorIdx >= 0); + assert (colorIdx < MAX_COLOR_REGISTERS); + + sixels[imageX][imageY] = colorIdx; + } + } + + for (int i = 0; i < MAX_COLOR_REGISTERS; i++) { + boolean isUsed = false; + for (int imageX = 0; imageX < image.getWidth(); imageX++) { + for (int j = 0; j < 6; j++) { + if (sixels[imageX][j] == i) { + isUsed = true; + } + } + } + if (isUsed == false) { + continue; + } + + // Set to the beginning of scan line for the next set of + // colored pixels, and select the color. + sb.append(String.format("$#%d", i)); + + for (int imageX = 0; imageX < image.getWidth(); imageX++) { + + // Add up all the pixels that match this color. + int data = 0; + for (int j = 0; + (j < 6) && (currentRow + j < fullHeight); + j++) { + + if (sixels[imageX][j] == i) { + switch (j) { + case 0: + data += 1; + break; + case 1: + data += 2; + break; + case 2: + data += 4; + break; + case 3: + data += 8; + break; + case 4: + data += 16; + break; + case 5: + data += 32; + break; + } + } + } + assert (data >= 0); + assert (data < 127); + data += 63; + sb.append((char) data); + } // for (int imageX = 0; imageX < image.getWidth(); imageX++) + } // for (int i = 0; i < MAX_COLOR_REGISTERS; i++) + + // Advance to the next scan line. + sb.append("-"); + + } // for (int currentRow = 0; currentRow < imageHeight; currentRow += 6) + + // Kill the very last "-", because it is unnecessary. + sb.deleteCharAt(sb.length() - 1); + + if (saveInCache) { + // This row is OK to save into the cache. + sixelCache.put(cells, sb.toString()); + } + + return (startSixel(x, y) + sb.toString() + endSixel()); + } + + // ------------------------------------------------------------------------ + // End sixel output support ----------------------------------------------- + // ------------------------------------------------------------------------ + /** * Create a SGR parameter sequence for a single color change. * @@ -1773,9 +2973,9 @@ public class ECMA48Terminal extends LogicalScreen */ private String colorRGB(final int colorRGB, final boolean foreground) { - int colorRed = (colorRGB >> 16) & 0xFF; - int colorGreen = (colorRGB >> 8) & 0xFF; - int colorBlue = colorRGB & 0xFF; + int colorRed = (colorRGB >>> 16) & 0xFF; + int colorGreen = (colorRGB >>> 8) & 0xFF; + int colorBlue = colorRGB & 0xFF; StringBuilder sb = new StringBuilder(); if (foreground) { @@ -1797,12 +2997,12 @@ public class ECMA48Terminal extends LogicalScreen * e.g. "\033[42m" */ private String colorRGB(final int foreColorRGB, final int backColorRGB) { - int foreColorRed = (foreColorRGB >> 16) & 0xFF; - int foreColorGreen = (foreColorRGB >> 8) & 0xFF; - int foreColorBlue = foreColorRGB & 0xFF; - int backColorRed = (backColorRGB >> 16) & 0xFF; - int backColorGreen = (backColorRGB >> 8) & 0xFF; - int backColorBlue = backColorRGB & 0xFF; + int foreColorRed = (foreColorRGB >>> 16) & 0xFF; + int foreColorGreen = (foreColorRGB >>> 8) & 0xFF; + int foreColorBlue = foreColorRGB & 0xFF; + int backColorRed = (backColorRGB >>> 16) & 0xFF; + int backColorGreen = (backColorRGB >>> 8) & 0xFF; + int backColorBlue = backColorRGB & 0xFF; StringBuilder sb = new StringBuilder(); sb.append(String.format("\033[38;2;%d;%d;%dm", @@ -2051,12 +3251,12 @@ public class ECMA48Terminal extends LogicalScreen final boolean bold, final boolean reverse, final boolean blink, final boolean underline) { - int foreColorRed = (foreColorRGB >> 16) & 0xFF; - int foreColorGreen = (foreColorRGB >> 8) & 0xFF; - int foreColorBlue = foreColorRGB & 0xFF; - int backColorRed = (backColorRGB >> 16) & 0xFF; - int backColorGreen = (backColorRGB >> 8) & 0xFF; - int backColorBlue = backColorRGB & 0xFF; + int foreColorRed = (foreColorRGB >>> 16) & 0xFF; + int foreColorGreen = (foreColorRGB >>> 8) & 0xFF; + int foreColorBlue = foreColorRGB & 0xFF; + int backColorRed = (backColorRGB >>> 16) & 0xFF; + int backColorGreen = (backColorRGB >>> 8) & 0xFF; + int backColorBlue = backColorRGB & 0xFF; StringBuilder sb = new StringBuilder(); if ( bold && reverse && blink && !underline ) {