diff --git a/terminal-emulator/src/main/java/com/termux/terminal/ITermImage.java b/terminal-emulator/src/main/java/com/termux/terminal/ITermImage.java new file mode 100644 index 0000000000..8add088ae2 --- /dev/null +++ b/terminal-emulator/src/main/java/com/termux/terminal/ITermImage.java @@ -0,0 +1,396 @@ +package com.termux.terminal; + +import android.util.Base64; + +import java.util.Arrays; + +/** + * An iTerm image received via `OSC 1337`. + * + * - https://iterm2.com/documentation-images.html + */ +public class ITermImage { + + public static final String LOG_TAG = "ITermImage"; + + + + /** The {@link Enum} that defines {@link ITermImage} state. */ + public enum ImageState { + + INIT("init", 0), + ARGUMENTS_READ("arguments_read", 1), + IMAGE_READING("image_reading", 2), + IMAGE_READ("image_read", 3), + IMAGE_DECODED("image_decoded", 4), + FAILED("Failed", 5); + + private final String name; + private final int value; + + ImageState(final String name, final int value) { + this.name = name; + this.value = value; + } + + public String getName() { + return name; + } + + public int getValue() { + return value; + } + + } + + + + protected final TerminalSessionClient mClient; + + protected final boolean mIsMultipart; + + protected int mWidth = -1; + protected int mHeight = -1; + + protected boolean mInline = false; + + protected boolean mPreserveAspectRatio = true; + + protected final StringBuilder mEncodedImage = new StringBuilder(/* Initial capacity. */ 4096); + protected byte[] mDecodedImage; + + /** The current state of the {@link ImageState}. */ + protected ImageState mCurrentState = ImageState.INIT; + /** The previous state of the {@link ImageState}. */ + protected ImageState mPreviousState = ImageState.INIT; + + + + protected ITermImage(TerminalSessionClient client, boolean isMultiPart) { + mClient = client; + + mIsMultipart = isMultiPart; + } + + + + public TerminalSessionClient getClient() { + return mClient; + } + + + public boolean isMultipart() { + return mIsMultipart; + } + + + public int getWidth() { + return mWidth; + } + + public int getHeight() { + return mHeight; + } + + + public boolean isInline() { + return mInline; + } + + + public boolean shouldPreserveAspectRatio() { + return mPreserveAspectRatio; + } + + + public String getEncodedImage() { + return mEncodedImage.toString(); + } + + public byte[] getDecodedImage() { + return mDecodedImage; + } + + + public synchronized ImageState getCurrentState() { + return mCurrentState; + } + + public synchronized ImageState getPreviousState() { + return mPreviousState; + } + + + protected synchronized boolean setState(ImageState newState) { + // The state transition cannot go back or change if already at `ImageState.IMAGE_DECODED` + if (newState.getValue() < mCurrentState.getValue() || mCurrentState == ImageState.IMAGE_DECODED) { + Logger.logError(mClient, LOG_TAG, "Invalid image state transition from \"" + mCurrentState.getName() + "\" to " + "\"" + newState.getName() + "\""); + return false; + } + + // The `ImageState.FAILED` can be set again, like to add more errors, but we don't update + // `mPreviousState` with the `mCurrentState` value if its at `ImageState.FAILED` to + // preserve the last valid state. + if (mCurrentState != ImageState.FAILED) + mPreviousState = mCurrentState; + + mCurrentState = newState; + return true; + } + + + protected synchronized boolean setStateFailed(String error) { + if (error != null) { + Logger.logError(mClient, LOG_TAG, error); + } + return setState(ImageState.FAILED); + } + + + protected synchronized boolean ensureState(ImageState expectedState) { + return ensureState(expectedState, null); + } + + protected synchronized boolean ensureState(ImageState expectedState, String functionName) { + if (mCurrentState != expectedState) { + Logger.logError(mClient, LOG_TAG, "The current image state is \"" + mCurrentState.getName() + "\" but expected \"" + expectedState.getName() + "\"" + + (functionName != null ? " while calling '" + functionName : "'") + + " for " + (!mIsMultipart ? "singlepart" : "multipart") + " image"); + return false; + } + return true; + } + + + public synchronized boolean isArgumentsRead() { + return mCurrentState == ImageState.ARGUMENTS_READ; + } + + public synchronized boolean isImageReading() { + return mCurrentState == ImageState.IMAGE_READING; + } + + public synchronized boolean isImageRead() { + return mCurrentState == ImageState.IMAGE_READ; + } + + public synchronized boolean isImageDecoded() { + return mCurrentState == ImageState.IMAGE_DECODED; + } + + + + public synchronized int readArguments(TerminalEmulator terminalEmulator, StringBuilder oscArgs, int index) { + if (!ensureState(ImageState.INIT, "ImageState.readArguments()")) { + return -1; + } + + boolean lastParam = false; + while (index < oscArgs.length()) { + char ch = oscArgs.charAt(index); + // End of optional arguments. + if (ch == ':' && !mIsMultipart) { + break; + } else if (ch == ' ') { + index++; + continue; + } + + int keyEndIndex = oscArgs.indexOf("=", index); + if (keyEndIndex == -1) { + setStateFailed("The key for an argument not found after index " + index + " in osc argument string: " + oscArgs); + return -1; + } + String argKey = oscArgs.substring(index, keyEndIndex); + + int valueEndIndex = oscArgs.indexOf(";", keyEndIndex); + if (valueEndIndex == -1) { + if (!mIsMultipart) { + // The last key value for `File=` command arguments may end with a colon `:` instead of a semi colon `;`. + valueEndIndex = oscArgs.indexOf(":", keyEndIndex); + if (valueEndIndex == -1) { + setStateFailed("The value for an argument not found after index " + index + " in osc argument string: " + oscArgs); + return -1; + } else { + index = valueEndIndex; + lastParam = true; + } + } else { + // The last key value for `MultipartFile=` command arguments may end without a semi colon `;`. + valueEndIndex = oscArgs.length(); + index = valueEndIndex; + } + } else { + index = valueEndIndex + 1; + } + + if (valueEndIndex <= keyEndIndex) { + setStateFailed("The argument key end index " + keyEndIndex + " is <= to value end index " + valueEndIndex + " in osc argument string: " + oscArgs); + return -1; + } + + String argValue = oscArgs.substring(keyEndIndex + 1, valueEndIndex); + + if (argKey.equalsIgnoreCase("inline")) { + mInline = argValue.equals("1"); + } + else if (argKey.equalsIgnoreCase("preserveAspectRatio")) { + mPreserveAspectRatio = !argValue.equals("0"); + } + else if (argKey.equalsIgnoreCase("width")) { + double factor = terminalEmulator.getCellWidthPixels(); + int intValueEndIndex = argValue.length(); + if (argValue.endsWith("px")) { + factor = 1; + intValueEndIndex -= 2; + } else if (argValue.endsWith("%")) { + factor = 0.01 * terminalEmulator.getCellWidthPixels() * terminalEmulator.getColumns(); + intValueEndIndex -= 1; + } + try { + mWidth = (int) (factor * Integer.parseInt(argValue.substring(0, intValueEndIndex))); + } catch (Exception e) { + } + } + else if (argKey.equalsIgnoreCase("height")) { + double factor = terminalEmulator.getCellHeightPixels(); + int intValueEndIndex = argValue.length(); + if (argValue.endsWith("px")) { + factor = 1; + intValueEndIndex -= 2; + } else if (argValue.endsWith("%")) { + factor = 0.01 * terminalEmulator.getCellHeightPixels() * terminalEmulator.getRows(); + intValueEndIndex -= 1; + } + try { + mHeight = (int) (factor * Integer.parseInt(argValue.substring(0, intValueEndIndex))); + } catch (Exception e) { + } + } else { + // `name` and `size` keys are not supported. + } + + if (lastParam) { + break; + } + } + + setState(ImageState.ARGUMENTS_READ); + + return index; + } + + + public synchronized boolean readImage(StringBuilder oscArgs, int index) { + if (!mIsMultipart) { + if (!ensureState(ImageState.ARGUMENTS_READ, "ImageState.readImage()")) { + return false; + } + + if (index < oscArgs.length()) { + int colonIndex = oscArgs.indexOf(":", index); + if (colonIndex >= 0 && colonIndex + 1 < oscArgs.length()) { + setState(ImageState.IMAGE_READING); + int imageStartIndex = colonIndex + 1; + + try { + // Appending can cause an increase in capacity and cause an OOM. + mEncodedImage.append(oscArgs.substring(imageStartIndex)); + } catch (Throwable t) { + if (t instanceof OutOfMemoryError) System.gc(); + setStateFailed("Collecting singlepart image" + " in osc argument string failed: " + t.getMessage()); + return false; + } + + setState(ImageState.IMAGE_READ); + return true; + } + } + + setStateFailed("Failed to read singlepart image from index " + index + " in osc argument string: " + oscArgs); + return false; + } else { + if (mCurrentState != ImageState.IMAGE_READING && + !ensureState(ImageState.ARGUMENTS_READ, "ImageState.readImage()")) { + return false; + } + + // An empty `FilePart=` command could be received as well, so change state before `if` below. + setState(ImageState.IMAGE_READING); + + if (index < oscArgs.length()) { + try { + // Appending can cause an increase in capacity and cause an OOM. + mEncodedImage.append(oscArgs.substring(index)); + } catch (Throwable t) { + if (t instanceof OutOfMemoryError) System.gc(); + setStateFailed("Collecting multipart image" + " in osc argument string failed: " + t.getMessage()); + return false; + } + return true; + } + + setStateFailed("Failed to read multipart image" + " in osc argument string: " + oscArgs); + return false; + } + } + + public synchronized boolean setMultiPartImageRead() { + if (!mIsMultipart) { + Logger.logError(mClient, LOG_TAG, "Attempting to call 'ImageState.setMultiPartImageRead()' for a singlepart image"); + return false; + } + + // A `FileEnd` command may have been received without a `FilePart=` command preceding it. + if (!ensureState(ImageState.IMAGE_READING, "ImageState.setMultiPartImageRead()")) { + return false; + } + + setState(ImageState.IMAGE_READ); + return true; + } + + + public synchronized boolean decodeImage() { + if (!ensureState(ImageState.IMAGE_READ, "ImageState.decodeImage()")) { + return false; + } + + String encodedImageString = null; + try { + if (mEncodedImage.length() < 1) { + setStateFailed("Cannot decoded an empty image"); + return false; + } + + while (mEncodedImage.length() % 4 != 0) { + mEncodedImage.append('='); + } + + encodedImageString = mEncodedImage.toString(); + + // Clear original encoded image from memory as it is no longer needed. + mEncodedImage.setLength(0); + mEncodedImage.trimToSize(); + + mDecodedImage = Base64.decode(encodedImageString, Base64.DEFAULT); + if (mDecodedImage == null || mDecodedImage.length < 2) { + setStateFailed("The decoded image is not valid: " + Arrays.toString(mDecodedImage) + "\nimage: " + encodedImageString); + return false; + } + + setState(ImageState.IMAGE_DECODED); + return true; + } catch (Throwable t) { + if (t instanceof OutOfMemoryError) { + Logger.logError(mClient, LOG_TAG, "Failed to decode image: " + t.getMessage()); + System.gc(); + } else { + Logger.logStackTraceWithMessage(mClient, LOG_TAG, "Failed to decode image: " + encodedImageString, t); + } + setStateFailed(null); + return false; + } + } + +} diff --git a/terminal-emulator/src/main/java/com/termux/terminal/TerminalBitmap.java b/terminal-emulator/src/main/java/com/termux/terminal/TerminalBitmap.java new file mode 100644 index 0000000000..773210e993 --- /dev/null +++ b/terminal-emulator/src/main/java/com/termux/terminal/TerminalBitmap.java @@ -0,0 +1,333 @@ +package com.termux.terminal; + +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; + +/** + * A terminal bitmap for images. + */ +public class TerminalBitmap { + + public static final String LOG_TAG = "TerminalBitmap"; + + + protected final TerminalSessionClient mClient; + + protected int mBitmapNum; + protected Bitmap mBitmap; + + protected int mCellWidth; + protected int mCellHeight; + + protected int mScrollLines; + + protected int[] mCursorDelta; + + + protected TerminalBitmap(TerminalSessionClient client, int bitmapNum, Bitmap bitmap, + int cellWidth, int cellHeight, + int scrollLines, int[] cursorDelta) { + mClient = client; + + mBitmapNum = bitmapNum; + mBitmap = bitmap; + + mCellWidth = cellWidth; + mCellHeight = cellHeight; + + mScrollLines = scrollLines; + mCursorDelta = cursorDelta; + } + + + + /** Build a {@link TerminalBitmap} from a {@link TerminalSixel}. */ + public static TerminalBitmap build(TerminalBuffer terminalBuffer, int bitmapNum, TerminalSixel terminalSixel, + int x, int y, int cellWidth, int cellHeight) { + try { + Bitmap bitmap = terminalSixel.getBitmap(); + bitmap = resizeBitmapConstrained(LOG_TAG, "sixel", terminalBuffer.getClient(), bitmap, + terminalSixel.getWidth(), terminalSixel.getHeight(), cellWidth, cellHeight, + terminalBuffer.mColumns - x); + if (bitmap == null) { + Logger.logError(terminalBuffer.getClient(), LOG_TAG, + "Create terminal bitmap " + bitmapNum + " from terminal sixel failed"); + return null; + } + + return buildOrThrow(terminalBuffer, bitmapNum, bitmap, + x, y, cellWidth, cellHeight); + } catch (Throwable t) { + if (t instanceof OutOfMemoryError) System.gc(); + Logger.logError(terminalBuffer.getClient(), LOG_TAG, + "Create terminal bitmap " + bitmapNum + " from terminal sixel failed: " + t.getMessage()); + return null; + } + } + + + /** Build a {@link TerminalBitmap} from an image `byte[]`. */ + public static TerminalBitmap build(TerminalBuffer terminalBuffer, int bitmapNum, byte[] image, + int x, int y, int cellWidth, int cellHeight, + int width, int height, boolean shouldPreserveAspectRatio) { + try { + Bitmap newBitmap; + int imageHeight; + int imageWidth; + int newWidth = width; + int newHeight = height; + + if (height > 0 || width > 0) { + // Get image dimensions without creating a bitmap. + BitmapFactory.Options options = new BitmapFactory.Options(); + options.inJustDecodeBounds = true; + try { + BitmapFactory.decodeByteArray(image, 0, image.length, options); + } catch (Throwable t) { + if (t instanceof OutOfMemoryError) System.gc(); + Logger.logWarn(terminalBuffer.getClient(), LOG_TAG, + "Decode bitmap failed while creating terminal bitmap " + bitmapNum + " from image byte array: " + t.getMessage()); + } + + + imageHeight = options.outHeight; + imageWidth = options.outWidth; + if (shouldPreserveAspectRatio) { + double wFactor = 9999.0; + double hFactor = 9999.0; + if (width > 0) { + wFactor = (double) width / imageWidth; + } + if (height > 0) { + hFactor = (double) height / imageHeight; + } + double factor = Math.min(wFactor, hFactor); + newWidth = (int) (factor * imageWidth); + newHeight = (int) (factor * imageHeight); + } else { + if (height <= 0) { + newHeight = imageHeight; + } + if (width <= 0) { + newWidth = imageWidth; + } + } + + int scaleFactor = 1; + while (imageHeight >= 2 * newHeight * scaleFactor && imageWidth >= 2 * newWidth * scaleFactor) { + scaleFactor = scaleFactor * 2; + } + + + // Create bitmap from image. + BitmapFactory.Options scaleOptions = new BitmapFactory.Options(); + // Subsample the original image to get a smaller image to save memory. + scaleOptions.inSampleSize = scaleFactor; + try { + newBitmap = BitmapFactory.decodeByteArray(image, 0, image.length, scaleOptions); + } catch (Throwable t) { + if (t instanceof OutOfMemoryError) System.gc(); + Logger.logError(terminalBuffer.getClient(), LOG_TAG, + "Create terminal bitmap " + bitmapNum + " from image byte array failed: Decode scaled bitmap failed: " + t.getMessage()); + return null; + } + if (newBitmap == null) { + Logger.logError(terminalBuffer.getClient(), LOG_TAG, + "Create terminal bitmap " + bitmapNum + " from image byte array failed: Decoded scaled bitmap not set"); + return null; + } + + + // Crop the bitmap if it exceeds terminal bounds. + int maxWidth = (terminalBuffer.mColumns - x) * cellWidth; + if (newWidth > maxWidth) { + int cropWidth = newBitmap.getWidth() * maxWidth / newWidth; + try { + newBitmap = Bitmap.createBitmap(newBitmap, 0, 0, cropWidth, newBitmap.getHeight()); + newWidth = maxWidth; + } catch (Throwable t) { + if (t instanceof OutOfMemoryError) { + // This is just a memory optimization. If it fails, + // continue (and probably fail later). + System.gc(); + } + + } + } + + + // Create final scaled bitmap. + try { + newBitmap = Bitmap.createScaledBitmap(newBitmap, newWidth, newHeight, true); + } catch (Throwable t) { + if (t instanceof OutOfMemoryError) System.gc(); + Logger.logError(terminalBuffer.getClient(), LOG_TAG, + "Create terminal bitmap " + bitmapNum + " from image byte array failed: Create scaled bitmap failed: " + t.getMessage()); + return null; + } + } else { + // Create bitmap from image. + try { + newBitmap = BitmapFactory.decodeByteArray(image, 0, image.length); + } catch (Throwable t) { + if (t instanceof OutOfMemoryError) System.gc(); + Logger.logError(terminalBuffer.getClient(), LOG_TAG, + "Create terminal bitmap " + bitmapNum + " from image byte array failed: Create full bitmap failed: " + t.getMessage()); + return null; + } + } + + if (newBitmap == null) { + Logger.logError(terminalBuffer.getClient(), LOG_TAG, + "Create terminal bitmap " + bitmapNum + " from image byte array failed: New bitmap not set"); + return null; + } + + + newBitmap = resizeBitmapConstrained(LOG_TAG, "image byte array", terminalBuffer.getClient(), newBitmap, + newBitmap.getWidth(), newBitmap.getHeight(), cellWidth, cellHeight, + terminalBuffer.mColumns - x); + TerminalBitmap terminalBitmap = build(terminalBuffer, bitmapNum, newBitmap, x, y, cellWidth, cellHeight); + if (terminalBitmap == null) { + return terminalBitmap; + } + + terminalBitmap.setCursorDelta(new int[] {terminalBitmap.getScrollLines(), (terminalBitmap.getBitmap().getWidth() + cellWidth - 1) / cellWidth}); + + return terminalBitmap; + } catch (Throwable t) { + if (t instanceof OutOfMemoryError) System.gc(); + Logger.logError(terminalBuffer.getClient(), LOG_TAG, + "Create terminal bitmap " + bitmapNum + " from image byte array failed: " + t.getMessage()); + return null; + } + } + + + /** Build a {@link TerminalBitmap} from a {@link Bitmap}. */ + public static TerminalBitmap build(TerminalBuffer terminalBuffer, int bitmapNum, Bitmap bitmap, + int x, int y, int cellWidth, int cellHeight) { + try { + return buildOrThrow(terminalBuffer, bitmapNum, bitmap, x, y, cellWidth, cellHeight); + } catch (Throwable t) { + if (t instanceof OutOfMemoryError) System.gc(); + Logger.logError(terminalBuffer.getClient(), LOG_TAG, + "Create terminal bitmap " + bitmapNum + " from bitmap failed: " + t.getMessage()); + return null; + } + } + + /** Build a {@link TerminalBitmap} from a {@link Bitmap}. */ + public static TerminalBitmap buildOrThrow(TerminalBuffer terminalBuffer, int bitmapNum, Bitmap bitmap, + int x, int y, int cellWidth, int cellHeight) throws Throwable { + if (bitmap == null) { + throw new IllegalArgumentException("Cannot create terminal bitmap from an unset bitmap"); + } + + int bitmapWidth = bitmap.getWidth(); + int bitmapHeight = bitmap.getHeight(); + int width = Math.min(terminalBuffer.mColumns - x, (bitmapWidth + cellWidth - 1) / cellWidth); + int height = (bitmapHeight + cellHeight - 1) / cellHeight; + int s = 0; + + for (int i = 0; i < height; i++) { + if (y + i - s == terminalBuffer.mScreenRows) { + terminalBuffer.scrollDownOneLine(0, terminalBuffer.mScreenRows, TextStyle.NORMAL); + s++; + } + for (int j = 0; j < width ; j++) { + terminalBuffer.setChar(x + j, y + i - s, '+', TextStyle.encodeTerminalBitmap(bitmapNum, j, i)); + } + } + + if (width * cellWidth < bitmapWidth) { + bitmap = Bitmap.createBitmap(bitmap, 0, 0, width * cellWidth, bitmapHeight); + } + + int scrollLines = height - s; + + return new TerminalBitmap(terminalBuffer.getClient(), bitmapNum, bitmap, + cellWidth, cellHeight, scrollLines, null); + } + + + + public TerminalSessionClient getClient() { + return mClient; + } + + + public int getBitmapNum() { + return mBitmapNum; + } + + public Bitmap getBitmap() { + return mBitmap; + } + + + public int getCellWidth() { + return mCellWidth; + } + + public int getCellHeight() { + return mCellHeight; + } + + + public int getScrollLines() { + return mScrollLines; + } + + + public int[] getCursorDelta() { + return mCursorDelta; + } + + public void setCursorDelta(int[] cursorDelta) { + mCursorDelta = cursorDelta; + } + + + + + + public static Bitmap resizeBitmap(String logTag, String label, TerminalSessionClient client, Bitmap bitmap, + int bitmapWidth, int bitmapHeight) { + + Bitmap newBitmap; + try { + int[] pixels = new int[bitmap.getAllocationByteCount()]; + + bitmap.getPixels(pixels, 0, bitmap.getWidth(), 0, 0, bitmap.getWidth(), bitmap.getHeight()); + newBitmap = Bitmap.createBitmap(bitmapWidth, bitmapHeight, Bitmap.Config.ARGB_8888); + + int newWidth = Math.min(bitmap.getWidth(), bitmapWidth); + int newHeight = Math.min(bitmap.getHeight(), bitmapHeight); + newBitmap.setPixels(pixels, 0, bitmap.getWidth(), 0, 0, newWidth, newHeight); + return newBitmap; + } catch (Throwable t) { + if (t instanceof OutOfMemoryError) System.gc(); + Logger.logError(client, logTag, "Resize " + label + " bitmap to width " + bitmapWidth + " and height " + bitmapHeight + " failed: " + t.getMessage()); + return null; + } + } + + public static Bitmap resizeBitmapConstrained(String logTag, String label, TerminalSessionClient client, Bitmap bitmap, + int bitmapWidth, int bitmapHeight, + int cellWidth, int cellHeight, int columns) { + // Width and height must be multiples of the cell width and height. + // Bitmap should not extend beyond screen width. + Bitmap originalBitmap = bitmap; + if (bitmapWidth > cellWidth * columns || (bitmapWidth % cellWidth) != 0 || (bitmapHeight % cellHeight) != 0) { + int newBitmapWidth = Math.min(cellWidth * columns, ((bitmapWidth - 1) / cellWidth) * cellWidth + cellWidth); + int newBitmapHeight = ((bitmapHeight - 1) / cellHeight) * cellHeight + cellHeight; + bitmap = resizeBitmap(logTag, label, client, originalBitmap, newBitmapWidth, newBitmapHeight); + // Only a minor display glitch if resize failed. + return bitmap != null ? bitmap : originalBitmap; + } else { + return originalBitmap; + } + } + +} diff --git a/terminal-emulator/src/main/java/com/termux/terminal/TerminalBuffer.java b/terminal-emulator/src/main/java/com/termux/terminal/TerminalBuffer.java index 21d6518785..1385f156ff 100644 --- a/terminal-emulator/src/main/java/com/termux/terminal/TerminalBuffer.java +++ b/terminal-emulator/src/main/java/com/termux/terminal/TerminalBuffer.java @@ -1,6 +1,14 @@ package com.termux.terminal; import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; +import java.util.HashMap; + +import android.graphics.Bitmap; +import android.graphics.Rect; + +import android.os.SystemClock; /** * A circular buffer of {@link TerminalRow}:s which keeps notes about what is visible on a logical screen and the scroll @@ -10,6 +18,12 @@ */ public final class TerminalBuffer { + public static final String LOG_TAG = "TerminalBuffer"; + + + + private TerminalSessionClient mClient; + TerminalRow[] mLines; /** The length of {@link #mLines}. */ int mTotalRows; @@ -20,23 +34,74 @@ public final class TerminalBuffer { /** The index in the circular buffer where the visible screen starts. */ private int mScreenFirstRow = 0; + + /** + * The {@link TerminalSixel} if a sixel command is being processed, from which the final + * {@link TerminalBitmap} is created. + */ + private TerminalSixel mTerminalSixel; + + + /** The map for bitmap number to the {@link TerminalBitmap} loaded in the terminal. */ + private final HashMap mTerminalBitmaps; + + /** The time since last garbage collection for all the {@link TerminalBitmap} that are loaded in the terminal. */ + private long mTerminalBitmapsLastGC; + + /** + * The bitmap number start for {@link #mTerminalBitmaps} keys. + * + * The bitmap number and coordinates are encoded in the `long` {@link TerminalRow#mStyle} for + * the `TerminalRow` character of a column by + * {@link TerminalBitmap#buildOrThrow(TerminalBuffer, int, Bitmap, int, int, int, int)} by + * getting encoded value from {@link TextStyle#encodeTerminalBitmap(int, int, int)}. + * The `TerminalRenderer.render()` then checks during rendering terminal output whether a + * character at a row/coloumn index is a bitmap instead of text by calling + * `TextStyle.isTerminalBitmap()`. + */ + public static final int TERMINAL_BITMAP__NUM_START = 0; + + /** + * The bitmap number end for {@link #mTerminalBitmaps} keys. + */ + public static final int TERMINAL_BITMAP__NUM_END = Integer.MAX_VALUE; + + + + + public TerminalBuffer(int columns, int totalRows, int screenRows) { + this(null, columns, totalRows, screenRows); + } + /** * Create a transcript screen. * + * @param client the {@link TerminalSessionClient}. * @param columns the width of the screen in characters. * @param totalRows the height of the entire text area, in rows of text. * @param screenRows the height of just the screen, not including the transcript that holds lines that have scrolled off * the top of the screen. */ - public TerminalBuffer(int columns, int totalRows, int screenRows) { + public TerminalBuffer(TerminalSessionClient client, int columns, int totalRows, int screenRows) { + mClient = client; + mColumns = columns; mTotalRows = totalRows; mScreenRows = screenRows; mLines = new TerminalRow[totalRows]; blockSet(0, 0, columns, screenRows, ' ', TextStyle.NORMAL); + mTerminalBitmaps = new HashMap<>(); + mTerminalBitmapsLastGC = SystemClock.uptimeMillis(); } + + + public TerminalSessionClient getClient() { + return mClient; + } + + public String getTranscriptText() { return getSelectedText(0, -getActiveTranscriptRows(), mColumns, mScreenRows).trim(); } @@ -401,6 +466,10 @@ public void scrollDownOneLine(int topMargin, int bottomMargin, long style) { if (mLines[blankRow] == null) { mLines[blankRow] = new TerminalRow(mColumns, style); } else { + // Remove bitmaps that are completely scrolled out. + if(mLines[blankRow].mHasTerminalBitmap) { + removeScrolledOutTerminalBitmaps(blankRow); + } mLines[blankRow].clear(style); } } @@ -439,9 +508,13 @@ public void blockSet(int sx, int sy, int w, int h, int val, long style) { throw new IllegalArgumentException( "Illegal arguments! blockSet(" + sx + ", " + sy + ", " + w + ", " + h + ", " + val + ", " + mColumns + ", " + mScreenRows + ")"); } - for (int y = 0; y < h; y++) + for (int y = 0; y < h; y++) { for (int x = 0; x < w; x++) setChar(sx + x, sy + y, val, style); + if (sx + w == mColumns && val == ' ') { + clearLineWrap(sy + y); + } + } } public TerminalRow allocateFullLineIfNecessary(int row) { @@ -484,7 +557,7 @@ public void setOrClearEffect(int bits, boolean setOrClear, boolean reverse, bool } } - public void clearTranscript() { + public synchronized void clearTranscript() { if (mScreenFirstRow < mActiveTranscriptRows) { Arrays.fill(mLines, mTotalRows + mScreenFirstRow - mActiveTranscriptRows, mTotalRows, null); Arrays.fill(mLines, 0, mScreenFirstRow, null); @@ -492,6 +565,196 @@ public void clearTranscript() { Arrays.fill(mLines, mScreenFirstRow - mActiveTranscriptRows, mScreenFirstRow, null); } mActiveTranscriptRows = 0; + clearTerminalBitmaps(); + } + + + + public synchronized TerminalBitmap getTerminalBitmap(long style) { + int bitmapNum = TextStyle.getTerminalBitmapNum(style); + return bitmapNum >= TERMINAL_BITMAP__NUM_START ? mTerminalBitmaps.get(bitmapNum): null; + } + + public synchronized void clearTerminalBitmaps() { + mTerminalBitmaps.clear(); + } + + public synchronized Bitmap getSixelBitmap(long style) { + TerminalBitmap terminalBitmap = getTerminalBitmap(style); + return terminalBitmap != null ? terminalBitmap.mBitmap : null; + } + + + public synchronized Rect getSixelRect(long style) { + TerminalBitmap terminalBitmap = getTerminalBitmap(style); + if (terminalBitmap == null) { + return null; + } + + int x = TextStyle.getTerminalBitmapX(style); + int y = TextStyle.getTerminalBitmapY(style); + return new Rect( + x * terminalBitmap.mCellWidth, + y * terminalBitmap.mCellHeight, + (x + 1) * terminalBitmap.mCellWidth, + (y + 1) * terminalBitmap.mCellHeight); + } + + + public synchronized void sixelStart(int width, int height) { + mTerminalSixel = TerminalSixel.build(getClient(), width, height); + } + + public synchronized int sixelEnd(int x, int y, int cellW, int cellH) { + if (mTerminalSixel == null) return 0; + + int bitmapNum = getFreeTerminalBitmapNum(); + if (bitmapNum < TERMINAL_BITMAP__NUM_START) { + Logger.logError(mClient, LOG_TAG, "Cannot create more than " + TERMINAL_BITMAP__NUM_END + " bitmaps"); + return 0; + } + + TerminalBitmap terminalBitmap = TerminalBitmap.build(this, bitmapNum, mTerminalSixel, x, y, cellW, cellH); + mTerminalSixel = null; + if (terminalBitmap == null || terminalBitmap.getBitmap() == null) { + return 0; + } + mTerminalBitmaps.put(bitmapNum, terminalBitmap); + + doTerminalBitmapsGC(30000); + return terminalBitmap.mScrollLines; + } + + public synchronized void sixelClear() { + mTerminalSixel = null; + } + + public synchronized boolean sixelReadData(int codePoint, int repeat) { + // If an error occurred during processing (like OOM), then remaining sixel command is + // completely read, but is ignored. + if (mTerminalSixel != null) { + if (!mTerminalSixel.readData(codePoint, repeat)) { + // Ignore further commands/data. + mTerminalSixel = null; + return false; + } + } + return true; + } + + public synchronized boolean sixelResize(int sixelWidth, int sixelHeight) { + // If an error occurred during processing (like OOM), then remaining sixel command is + // completely read, but is ignored. + if (mTerminalSixel != null) { + if (!mTerminalSixel.resize(sixelWidth, sixelHeight)) { + // Ignore further commands/data. + mTerminalSixel = null; + return false; + } + } + return true; + } + + public synchronized void sixelSetColor(int color) { + if (mTerminalSixel != null) + mTerminalSixel.setColor(color); + } + + public synchronized void sixelSetColor(int color, int r, int g, int b) { + if (mTerminalSixel != null) + mTerminalSixel.setRGBColor(color, r, g, b); + } + + + + private synchronized int getFreeTerminalBitmapNum() { + int bitmapNum = TERMINAL_BITMAP__NUM_START; + while (mTerminalBitmaps.containsKey(bitmapNum)) { + bitmapNum++; + if (bitmapNum == TERMINAL_BITMAP__NUM_END) { + return -1; + } + } + return bitmapNum; + } + + + public synchronized int[] addTerminalBitmapForImage(byte[] image, int x, int y, int cellW, int cellH, int width, int height, boolean shouldPreserveAspectRatio) { + int bitmapNum = getFreeTerminalBitmapNum(); + if (bitmapNum < TERMINAL_BITMAP__NUM_START) { + Logger.logError(mClient, LOG_TAG, "Cannot create more than " + TERMINAL_BITMAP__NUM_END + " bitmaps"); + return new int[] {0, 0}; + } + + TerminalBitmap terminalBitmap = TerminalBitmap.build(this, bitmapNum, image, x, y, + cellW, cellH, width, height, shouldPreserveAspectRatio); + if (terminalBitmap == null || terminalBitmap.getBitmap() == null) { + return new int[] {0, 0}; + } + mTerminalBitmaps.put(bitmapNum, terminalBitmap); + + doTerminalBitmapsGC(30000); + return terminalBitmap.mCursorDelta; + } + + + /** Remove bitmaps that are completely scrolled out. */ + public synchronized void removeScrolledOutTerminalBitmaps(int row) { + Set bitmapsToRemove = new HashSet<>(); + + for (int column = 0; column < mColumns; column++) { + long columnStyle = mLines[row].getStyle(column); + int bitmapNum = TextStyle.getTerminalBitmapNum(columnStyle); + if (bitmapNum >= TERMINAL_BITMAP__NUM_START) { + bitmapsToRemove.add(bitmapNum); + } + } + + if (row + 1 < mTotalRows) { + TerminalRow nextLine = mLines[row + 1]; + if (nextLine.mHasTerminalBitmap) { + for (int column = 0; column < mColumns; column++) { + long columnStyle = nextLine.getStyle(column); + int bitmapNum = TextStyle.getTerminalBitmapNum(columnStyle); + if (bitmapNum >= TERMINAL_BITMAP__NUM_START) { + bitmapsToRemove.add(bitmapNum); + } + } + } + } + + for(Integer bitmapStyle : bitmapsToRemove) { + mTerminalBitmaps.remove(bitmapStyle); + } + } + + public synchronized void doTerminalBitmapsGC(int timeDelta) { + if (mTerminalBitmaps.isEmpty() || mTerminalBitmapsLastGC + timeDelta > SystemClock.uptimeMillis()) { + return; + } + + Set bitmapsToKeep = new HashSet<>(); + + for (int line = 0; line < mLines.length; line++) { + if(mLines[line] != null && mLines[line].mHasTerminalBitmap) { + for (int column = 0; column < mColumns; column++) { + long style = mLines[line].getStyle(column); + int bitmapNum = TextStyle.getTerminalBitmapNum(style); + if (bitmapNum >= TERMINAL_BITMAP__NUM_START) { + bitmapsToKeep.add(bitmapNum); + } + } + } + } + + Set bitmapNums = new HashSet<>(mTerminalBitmaps.keySet()); + for (Integer bitmapNum: bitmapNums) { + if (!bitmapsToKeep.contains(bitmapNum)) { + mTerminalBitmaps.remove(bitmapNum); + } + } + + mTerminalBitmapsLastGC = SystemClock.uptimeMillis(); } } diff --git a/terminal-emulator/src/main/java/com/termux/terminal/TerminalEmulator.java b/terminal-emulator/src/main/java/com/termux/terminal/TerminalEmulator.java index b0be6f3440..8f7ab7a753 100644 --- a/terminal-emulator/src/main/java/com/termux/terminal/TerminalEmulator.java +++ b/terminal-emulator/src/main/java/com/termux/terminal/TerminalEmulator.java @@ -12,6 +12,8 @@ * Renders text into a screen. Contains all the terminal-specific knowledge and state. Emulates a subset of the X Window * System xterm terminal, which in turn is an emulator for a subset of the Digital Equipment Corporation vt100 terminal. *

+ * See also 7-bit Code Table defined at https://vt100.net/docs/vt220-rm/chapter2.html#S2.3.1 + *

* References: *

    *
  • http://invisible-island.net/xterm/ctlseqs/ctlseqs.html
  • @@ -41,6 +43,15 @@ public final class TerminalEmulator { /** Used for invalid data - http://en.wikipedia.org/wiki/Replacement_character#Replacement_character */ public static final int UNICODE_REPLACEMENT_CHAR = 0xFFFD; + /* + * Escape sequences starting with an ESC character. + * + * - https://vt100.net/docs/vt220-rm/chapter2.html#S2.5.1 + * - https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h3-Controls-beginning-with-ESC + * - https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h3-C1-lparen-8-Bit-rparen-Control-Characters + * - https://en.wikipedia.org/wiki/C0_and_C1_control_codes + */ + /** Escape processing: Not currently in an escape sequence. */ private static final int ESC_NONE = 0; /** Escape processing: Have seen an ESC character - proceed to {@link #doEsc(int)} */ @@ -59,14 +70,66 @@ public final class TerminalEmulator { private static final int ESC_CSI_DOLLAR = 8; /** Escape processing: ESC % */ private static final int ESC_PERCENT = 9; - /** Escape processing: ESC ] (AKA OSC - Operating System Controls) */ + /** + * Escape processing: `ESC ]` for Operating System Command (OSC) + *

    + * `OSC` commands may be in one of the following formats: + * - `OSC Ps ; Pt BEL` where `BEL` is the bell control passed as `\a`. + * - `OSC Ps ; Pt ST` where `ST` is the string terminator passed as `ESC \`. + * `ST` is the preferred standard for modern terminals. + *

    + * If an `OSC` escape sequence is received, then {@link #mEscapeState} is set to {@link #ESC_OSC} + * and {@link #receiveOsc(int)} is called by {@link #processCodePoint(int)}. + * - By default it will add bytes received after `OSC` escape sequence to {@link #mTerminalControlArgs}. + * - If a `BEL` is received, then {@link #doOsc(String)} is called to process + * the OSC command. + * - If an `ESC` is received, then {@link #mEscapeState} is set to {@link #ESC_OSC__ESC} and + * {@link #receiveOscEsc(int)} is called for the next code point. + * - If the next code point is a `\` for `ST`, then {@link #doOsc(String)} is + * called to process the OSC command. + * - If the next code point is not a `\`, then {@link #mEscapeState} is set back to + * {@link #ESC_OSC} as `ESC` may be part of command data as so it is added to + * {@link #mTerminalControlArgs}, and for later code points {@link #receiveOsc(int)} is called + * instead. + *

    + * While an `OSC` is being received, {@link #mOscType} may be set to the command type when it + * has been fully received by {@link #setOscTypeVariables()}. + *

    + * See also {@link #mIsFastPathOsc} for enabling fast path for specific `OSC` commands if required + * via {@link #setOscTypeVariables()}. + *

    + * See also {@link #mIgnoreCrLfForOsc} to prevent printing of CR/LF characters for specific + * `OSC` commands if required via {@link #setOscTypeVariables()}. + *

    + * - https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h3-Operating-System-Commands + */ private static final int ESC_OSC = 10; - /** Escape processing: ESC ] (AKA OSC - Operating System Controls) ESC */ - private static final int ESC_OSC_ESC = 11; + /** Escape processing: `ESC` received while receiving a {@link #ESC_OSC} command. */ + private static final int ESC_OSC__ESC = 11; /** Escape processing: ESC [ > */ private static final int ESC_CSI_BIGGERTHAN = 12; - /** Escape procession: "ESC P" or Device Control String (DCS) */ - private static final int ESC_P = 13; + /** + * Escape procession: `ESC P` for Device Control String (DCS) + *

    + * `DCS` commands are in the format `DCS data ST` where `ST` is the string terminator passed as `ESC \`. + * `data` is application defined raw data without any specific standards. + *

    + * If an `DCS` escape sequence is received, then {@link #mEscapeState} is set to {@link #ESC_DCS} + * and {@link #doDcs(int)} is called by {@link #processCodePoint(int)}. + * - By default it will add bytes received after `DCS` escape sequence to {@link #mTerminalControlArgs}. + * - If an `ESC` is received by {@link #processCodePoint(int)}, then {@link #ESC_DCS__ESC} set + * to `true`. + * - If the next code point is a `\` for `ST`, then {@link #doDcs(int)} processes the DCS command. + * - If the next code point is not a `\`, then {@link #ESC_DCS__ESC} is set back to `false` as + * `ESC` may be part of command data as so it is added to {@link #mTerminalControlArgs}. + * - For certain commands like sixel commands, {@link #doDcs(int)} alters the default behaviour. + *

    + * See also {@link #mIsFastPathDcs} for enabling fast path for specific `DCS` commands if required. + *

    + *

    + * - https://vt100.net/docs/vt220-rm/chapter2.html#S2.5.3 + */ + private static final int ESC_DCS = 13; /** Escape processing: CSI > */ private static final int ESC_CSI_QUESTIONMARK_ARG_DOLLAR = 14; /** Escape processing: CSI $ARGS ' ' */ @@ -79,10 +142,31 @@ public final class TerminalEmulator { private static final int ESC_CSI_SINGLE_QUOTE = 18; /** Escape processing: CSI ! */ private static final int ESC_CSI_EXCLAMATION = 19; - /** Escape processing: "ESC _" or Application Program Command (APC). */ + /** + * Escape processing: `ESC _` for Application Program Command (APC). + *

    + * `APC` commands are in the format `APC data ST` where `ST` is the string terminator passed as `ESC \`. + * `data` is application defined raw data without any specific standards. + *

    + * If an `APC` escape sequence is received, then {@link #mEscapeState} is set to {@link #ESC_APC} + * and {@link #receiveApc(int)} is called by {@link #processCodePoint(int)}. + * - By default it will add bytes received after `APC` escape sequence to {@link #mTerminalControlArgs}. + * - If an `ESC` is received, then {@link #mEscapeState} is set to {@link #ESC_APC__ESC} and + * {@link #receiveApcEsc(int)} is called for the next code point. + * - If the next code point is a `\` for `ST`, then {@link #doApc()} is called to + * process the APC command. + * - If the next code point is not a `\`, then {@link #mEscapeState} is set back to + * {@link #ESC_APC} as `ESC` may be part of command data as so it is added to + * {@link #mTerminalControlArgs}, and for later code points {@link #receiveApc(int)} is called + * instead. + *

    + * Currently, APC commands are only parsed, but ignored as none are supported. + *

    + * - https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h3-Application-Program-Command-functions + */ private static final int ESC_APC = 20; - /** Escape processing: "ESC _" or Application Program Command (APC), followed by Escape. */ - private static final int ESC_APC_ESCAPE = 21; + /** Escape processing: `ESC` received while receiving a {@link #ESC_APC} command. */ + private static final int ESC_APC__ESC = 21; /** Escape processing: ESC [ */ private static final int ESC_CSI_UNSUPPORTED_PARAMETER_BYTE = 22; /** Escape processing: ESC [ */ @@ -91,9 +175,6 @@ public final class TerminalEmulator { /** The number of parameter arguments including colon separated sub-parameters. */ private static final int MAX_ESCAPE_PARAMETERS = 32; - /** Needs to be large enough to contain reasonable OSC 52 pastes. */ - private static final int MAX_OSC_STRING_LENGTH = 8192; - /** DECSET 1 - application cursor keys. */ private static final int DECSET_BIT_APPLICATION_CURSOR_KEYS = 1; private static final int DECSET_BIT_REVERSE_VIDEO = 1 << 1; @@ -185,8 +266,87 @@ public final class TerminalEmulator { /** Holds the bit flags which arguments are sub parameters (after a colon) - bit N is set if mArgs[N] is a sub parameter. */ private int mArgsSubParamsBitSet = 0; - /** Holds OSC and device control arguments, which can be strings. */ - private final StringBuilder mOSCOrDeviceControlArgs = new StringBuilder(); + + + /** + * The initial capacity for {@link #mTerminalControlArgs}. + */ + private static final int TERMINAL_CONTROL_ARGS__INITIAL_CAPACITY = 16; + + /** + * The max length for {@link #mTerminalControlArgs}. + * Needs to be large enough to contain reasonable OSC 52 pastes, sixel and iterm images data. + */ + private static final int TERMINAL_CONTROL_ARGS__MAX_LENGTH = 16384; + + /** The terminal control arguments string buffer, like for OSC, DCS, APC commands. */ + private StringBuilder mTerminalControlArgs = new StringBuilder(TERMINAL_CONTROL_ARGS__INITIAL_CAPACITY); + + + + /** + * The integer Operating System Command `type` received as `ESC ] type ;`. + * This will be set as soon as `type` followed by `;` is received, and before any further + * optional parameters are received. + */ + private int mOscType = -1; + + /** + * If `true`, then `processCodePoint()` will directly call `receiveOsc()` as a fast path + * without additional checks. + * + * Can be enabled for OSC commands via {@link #setOscTypeVariables()}. + */ + private boolean mIsFastPathOsc = false; + + /** + * If `true`, then `processCodePoint()` will not print any CR/LF characters received. + * This is ignored if `mIsFastPathOsc` is already `true` for a command. + * + * Can be enabled for OSC commands via {@link #setOscTypeVariables()}. + */ + private boolean mIgnoreCrLfForOsc = false; + + + /** + * If `true`, then `processCodePoint()` will directly call `doDcs()` as a fast path + * without additional checks. + * + * Can be enabled for DCS commands via {@link #doDcs(int)}. + */ + private boolean mIsFastPathDcs = false; + + /** Whether processing an `ESC` for a DCS command. */ + private boolean ESC_DCS__ESC = false; + + + /** Whether processing a sixel `DCS q s..s ST` or `DCS P1; P2; P3; q s..s ST` command to create a {@link TerminalSixel}. */ + private boolean ESC_DCS__SIXEL = false; + + /** Whether to check if sixel command is being received when processing a DCS command. */ + private boolean ESC_DCS__CHECK_IF_SIXEL = true; + + /** The command part number in case a long sixel command was broken into parts for processing. */ + private int mSixelCommandPartNum; + + /** + * The capacity to set for {@link #mTerminalControlArgs} used to store sixel commands before processing. + * + * See also {@link #ensureTerminalControlArgsCapacity(int)}. + */ + private Integer mSixelArgsCapacity; + + /** + * The initial capacity for sixel args stored in {@link #mTerminalControlArgs}. + */ + private static final int SIXEL_ARGS__INITIAL_CAPACITY = 256; + + + + /** The {@link ITermImage} if an iTerm image command is being processed. */ + private ITermImage mITermImage; + + /** * True if the current escape sequence should continue, false if the current escape sequence should be terminated. @@ -327,8 +487,8 @@ static int mapDecSetBitToInternalBit(int decsetBit) { public TerminalEmulator(TerminalOutput session, int columns, int rows, int cellWidthPixels, int cellHeightPixels, Integer transcriptRows, TerminalSessionClient client) { mSession = session; - mScreen = mMainBuffer = new TerminalBuffer(columns, getTerminalTranscriptRows(transcriptRows), rows); - mAltBuffer = new TerminalBuffer(columns, rows, rows); + mScreen = mMainBuffer = new TerminalBuffer(client, columns, getTerminalTranscriptRows(transcriptRows), rows); + mAltBuffer = new TerminalBuffer(client, columns, rows, rows); mClient = client; mRows = rows; mColumns = columns; @@ -344,10 +504,28 @@ public void updateTerminalSessionClient(TerminalSessionClient client) { setCursorBlinkState(true); } + + public TerminalBuffer getScreen() { return mScreen; } + public int getRows() { + return mRows; + } + + public int getColumns() { + return mColumns; + } + + public int getCellWidthPixels() { + return mCellWidthPixels; + } + + public int getCellHeightPixels() { + return mCellHeightPixels; + } + public boolean isAlternateBufferActive() { return mScreen == mAltBuffer; } @@ -359,6 +537,8 @@ private int getTerminalTranscriptRows(Integer transcriptRows) { return transcriptRows; } + + /** * @param mouseButton one of the MOUSE_* constants of this class. */ @@ -568,12 +748,36 @@ private void processByte(byte byteToProcess) { } public void processCodePoint(int b) { + mScreen.doTerminalBitmapsGC(300000); + + if (mEscapeState == ESC_OSC && mIsFastPathOsc) { + mContinueSequence = false; + receiveOsc(b); + if (!mContinueSequence) mEscapeState = ESC_NONE; + return; + } + + if (mEscapeState == ESC_DCS && mIsFastPathDcs) { + if (b == 27) { // ESC + ESC_DCS__ESC = true; + return; + } + mContinueSequence = false; + doDcs(b); + if (!mContinueSequence) mEscapeState = ESC_NONE; + return; + } + // The Application Program-Control (APC) string might be arbitrary non-printable characters, so handle that early. if (mEscapeState == ESC_APC) { - doApc(b); + mContinueSequence = false; + receiveApc(b); + if (!mContinueSequence) mEscapeState = ESC_NONE; return; - } else if (mEscapeState == ESC_APC_ESCAPE) { - doApcEscape(b); + } else if (mEscapeState == ESC_APC__ESC) { + mContinueSequence = false; + receiveApcEsc(b); + if (!mContinueSequence) mEscapeState = ESC_NONE; return; } @@ -582,7 +786,7 @@ public void processCodePoint(int b) { break; case 7: // Bell (BEL, ^G, \a). If in an OSC sequence, BEL may terminate a string; otherwise signal bell. if (mEscapeState == ESC_OSC) - doOsc(b); + receiveOsc(b); else mSession.onBell(); break; @@ -611,10 +815,19 @@ public void processCodePoint(int b) { case 10: // Line feed (LF, \n). case 11: // Vertical tab (VT, \v). case 12: // Form feed (FF, \f). - doLinefeed(); + // Ignore CR/LF inside DCS by default (including sixel) or OSC if requested (like for iTerm). + if (! + (mEscapeState == ESC_DCS || + ((mEscapeState == ESC_OSC || mEscapeState == ESC_OSC__ESC) && mIgnoreCrLfForOsc))) { + doLinefeed(); + } break; case 13: // Carriage return (CR, \r). - setCursorCol(mLeftMargin); + if (! + (mEscapeState == ESC_DCS || + ((mEscapeState == ESC_OSC || mEscapeState == ESC_OSC__ESC) && mIgnoreCrLfForOsc))) { + setCursorCol(mLeftMargin); + } break; case 14: // Shift Out (Ctrl-N, SO) → Switch to Alternate Character Set. This invokes the G1 character set. mUseLineDrawingUsesG0 = false; @@ -632,13 +845,14 @@ public void processCodePoint(int b) { break; case 27: // ESC // Starts an escape sequence unless we're parsing a string - if (mEscapeState == ESC_P) { + if (mEscapeState == ESC_DCS) { // XXX: Ignore escape when reading device control sequence, since it may be part of string terminator. + ESC_DCS__ESC = true; return; } else if (mEscapeState != ESC_OSC) { startEscapeSequence(); } else { - doOsc(b); + receiveOsc(b); } break; default: @@ -838,13 +1052,13 @@ public void processCodePoint(int b) { case ESC_PERCENT: break; case ESC_OSC: - doOsc(b); + receiveOsc(b); break; - case ESC_OSC_ESC: - doOscEsc(b); + case ESC_OSC__ESC: + receiveOscEsc(b); break; - case ESC_P: - doDeviceControl(b); + case ESC_DCS: + doDcs(b); break; case ESC_CSI_QUESTIONMARK_ARG_DOLLAR: if (b == 'p') { @@ -914,13 +1128,40 @@ public void processCodePoint(int b) { } } - /** When in {@link #ESC_P} ("device control") sequence. */ - private void doDeviceControl(int b) { - switch (b) { - case (byte) '\\': // End of ESC \ string Terminator - { - String dcs = mOSCOrDeviceControlArgs.toString(); - // DCS $ q P t ST. Request Status String (DECRQSS) + + + /** + * Do {@link #ESC_DCS}. Check its docs for more info. + */ + private void doDcs(final int b) { + if ( + // End of DCS if string terminator ST `ESC \` received. + (ESC_DCS__ESC && b == '\\') + // If sixel continuation after sixel start and a + // Color Introducer `#`, Graphics Repeat Introducer `!` or Raster Attributes `"` + // command is received, then process any previous commands, or if end of input + // with a ST received. + // If `b` is a Color Introducer `#`, Graphics Repeat Introducer `!` or Raster Attributes `"` + // command, then it is added to buffer in code below and more input is waited for as + // further arguments need to be received for its command before it can be processed, + // which is not until the next command is received. + // We wait till at least `TERMINAL_CONTROL_ARGS__MAX_LENGTH / 2` commands string has + // been received. The divide by 2 is done since if near the max length, a new command + // starts and it does not end before the max length, then `Terminal control args overflow + // error would occur. + // If the first command has been fully received, then we run it immediately in case + // it is the Raster Attributes command containing the "rough" horizontal and vertical + // size of image, which is used to set the capacity of the `mTerminalControlArgs` buffer + // and also resize the bitmap, so that memory allocations are avoided if possible. + // `mTerminalControlArgs.length() > 1` is done so that loop does not engage on first + // character after `q` and only after first command has been fully received. + || (ESC_DCS__SIXEL && ((b == '#' || b == '!' || b == '"') && + ((mTerminalControlArgs.length() >= (TERMINAL_CONTROL_ARGS__MAX_LENGTH / 2)) || (mTerminalControlArgs.length() > 1 && mSixelCommandPartNum == 1)))) + ) { + String dcs = mTerminalControlArgs.toString(); + + // Request Selection or Setting (DECRQSS) `DCS $ q P t ST`. + // - https://vt100.net/docs/vt510-rm/DECRQSS.html if (dcs.startsWith("$q")) { if (dcs.equals("$q\"p")) { // DECSCL, conformance level, http://www.vt100.net/docs/vt510-rm/DECSCL: @@ -1018,49 +1259,365 @@ private void doDeviceControl(int b) { Logger.logError(mClient, LOG_TAG, "Invalid device termcap/terminfo name of odd length: " + part); } } + } + // If `s..s` or `ST` received from Sixel Device Control String `DCS q s..s ST` or `DCS P1; P2; P3; q s..s ST` command. + else if (ESC_DCS__SIXEL) { + mSixelCommandPartNum++; + + boolean isValidDcs = processSixelDcs(dcs); + + if (!isValidDcs) { + clearTerminalControlArgs(); + clearDcsTypeVariables(); + finishSequence(); + return; + } + + if (ESC_DCS__ESC && b == '\\') { + int n = mScreen.sixelEnd(mCursorCol, mCursorRow, mCellWidthPixels, mCellHeightPixels); + for(; n > 0; n--) { + doLinefeed(); + } + + // Clear DCS args buffer and variables and finish sequence. + } else { + ESC_DCS__ESC = false; + + // Clear DCS args buffer to receive further new input in empty buffer. + clearTerminalControlArgs(); + + // Increase capacity to expected capacity if `Raster Attributes` command + // was sent with image width and height, or to default + // `SIXEL_ARGS__INITIAL_CAPACITY` set by `startIfSixelDcs()`. + if (mSixelArgsCapacity != null) { + ensureTerminalControlArgsCapacity(mSixelArgsCapacity); + } + + // If `b` is a Color Introducer `#`, Graphics Repeat Introducer `!` or Raster Attributes `"` + // command, then add to buffer and wait for more input as further arguments + // need to be received for its command before it can be processed, which is + // not until the next command is received. + if (!collectTerminalControlArgs(b)) return; + + return; + } } else { if (LOG_ESCAPE_SEQUENCES) Logger.logError(mClient, LOG_TAG, "Unrecognized device control string: " + dcs); } + + // Clear DCS args buffer and variables and finish sequence. + clearTerminalControlArgs(); + clearDcsTypeVariables(); finishSequence(); - } - break; - default: - if (mOSCOrDeviceControlArgs.length() > MAX_OSC_STRING_LENGTH) { - // Too long. - mOSCOrDeviceControlArgs.setLength(0); - finishSequence(); - } else { - mOSCOrDeviceControlArgs.appendCodePoint(b); - continueSequence(mEscapeState); + } else { + ESC_DCS__ESC = false; + + if (!collectTerminalControlArgs(b)) return; + + if (ESC_DCS__CHECK_IF_SIXEL && !ESC_DCS__SIXEL) { + // Check if `DCS q` or `DCS P1; P2; P3; q` received from Sixel + // Device Control String `DCS q s..s ST` or `DCS P1; P2; P3; q s..s ST` command. + // If received, then wait for more input after `q`. + char ch = mTerminalControlArgs.charAt(0); + if (ch == 'q' || (ch >= '0' && ch <= '9')) { + startIfSixelDcs(); + } else { + ESC_DCS__CHECK_IF_SIXEL = false; + } } } } - /** - * When in {@link #ESC_APC} (APC, Application Program Command) sequence. - */ - private void doApc(int b) { - if (b == 27) { - continueSequence(ESC_APC_ESCAPE); + public void clearDcsTypeVariables() { + ESC_DCS__ESC = false; + mIsFastPathDcs = false; + + ESC_DCS__SIXEL = false; + ESC_DCS__CHECK_IF_SIXEL = true; + mSixelCommandPartNum = 0; + mSixelArgsCapacity = null; + mScreen.sixelClear(); + } + + + + private void startIfSixelDcs() { + int[] sixelDcsSetupArgs = getSixelDcsSetupArgs(mTerminalControlArgs.toString(), 0); + if (sixelDcsSetupArgs != null) { + + mIsFastPathDcs = true; + ESC_DCS__SIXEL = true; + ESC_DCS__CHECK_IF_SIXEL = false; + mSixelCommandPartNum = 1; + + // Do not actually increase capacity yet, as it will be increased by `doDcs()` after + // first command has been received, which is checked to see if its a `Raster Attributes` + // command with image width and height to calculate expected capacity. + mSixelArgsCapacity = SIXEL_ARGS__INITIAL_CAPACITY; + + // The `P1; P2; P3;` arguements in `sixelDcsSetupArgs` are ignored as they are not supported currently (if ever). + mScreen.sixelStart(100, 100); + clearTerminalControlArgs(); } - // Eat APC sequences silently for now. } - /** - * When in {@link #ESC_APC} (APC, Application Program Command) sequence. - */ - private void doApcEscape(int b) { - if (b == '\\') { - // A String Terminator (ST), ending the APC escape sequence. - finishSequence(); - } else { - // The Escape character was not the start of a String Terminator (ST), - // but instead just data inside of the APC escape sequence. - continueSequence(ESC_APC); + private int[] getSixelDcsSetupArgs(String dcs, int index) { + int[] args = {/* `P1=0`/`2:1` */ 0, /* `P2=0` */ 0, /* `P3=0` */ 0}; + + if (dcs.charAt(index) == 'q') return args; + + char ch; + + int arg = 0; boolean incArg = false; + while (index < dcs.length()) { + ch = dcs.charAt(index); + if (ch >= '0' && ch <= '9') { + if (incArg) { arg++; incArg = false; } + args[arg] = args[arg] * 10 + ch - '0'; + if (args[arg] < 0) { // Overflow. + break; + } + index++; + } else if (ch == ';') { + index++; + + if (arg == 2) { + if (index < dcs.length()) { + if (dcs.charAt(index) == 'q') { + return args; + } else { + // Must be some other command, so no need to check again. + ESC_DCS__CHECK_IF_SIXEL = false; + } + } + break; + } + + incArg = true; + } else { + break; + } + } + + return null; + } + + private boolean processSixelDcs(String dcs) { + int index = 0; + + char ch; + int repeat = 1; + int color; + boolean isValidDcs = true; + + while (index < dcs.length()) { + ch = dcs.charAt(index); + + if ( + // Sixel data characters in the range of `?` (0x3F) to `~` (0x7E). + // - https://vt100.net/docs/vt3xx-gp/chapter14.html#S14.2.1 + (ch >= '?' && ch <= '~') + // Graphics Carriage Return `$`. + // - https://vt100.net/docs/vt3xx-gp/chapter14.html#S14.3.4 + || ch == '$' + // Graphics New Line `-`. + // - https://vt100.net/docs/vt3xx-gp/chapter14.html#S14.3.5 + || ch == '-' + ) { + mScreen.sixelReadData(ch, repeat); + index++; + repeat = 1; + } + // Color Introducer `#` + // - https://vt100.net/docs/vt3xx-gp/chapter14.html#S14.3.3 + else if (ch == '#') { + index++; // Consume '#'. + + color = 0; + while (index < dcs.length()) { + ch = dcs.charAt(index); + if (ch >= '0' && ch <= '9') { + color = color * 10 + ch - '0'; + if (color > 255) { + Logger.logError(mClient, LOG_TAG, "The sixel color command Pc value " + color + " is not between 0-255 at index " + index + " of sixel input: " + dcs); + isValidDcs = false; + break; + } + index++; + } else { + break; + } + } + + if (!isValidDcs) { + break; + } + + // Basic Colors `# Pc` + // - https://vt100.net/docs/vt3xx-gp/chapter14.html#S14.3.3.1 + if (index == dcs.length() || dcs.charAt(index) != ';') { + mScreen.sixelSetColor(color); + } + // HLS or RGB Colors `# Pc; Pu; Px; Py; Pz` + // - https://vt100.net/docs/vt3xx-gp/chapter14.html#S14.3.3.2 + else if (dcs.charAt(index) == ';') { + index++; // Consume ';'. + + int[] args = {0, 0, 0, 0}; + int arg = 0; boolean incArg = false; + while (index < dcs.length()) { + ch = dcs.charAt(index); + if (ch >= '0' && ch <= '9') { + if (incArg) { arg++; incArg = false; } + args[arg] = args[arg] * 10 + ch - '0'; + if (arg == 0) { // Pu must equal 1 or 2. + if ((args[arg] != 1 && args[arg] != 2)) { + Logger.logError(mClient, LOG_TAG, "The sixel non-basic color command Pu value " + args[arg] + " is not 1 or 2 at index " + index + " of sixel input: " + dcs); + isValidDcs = false; + break; + } + } else { + int limit = 100; + if (args[0] == 1 && arg == 1) limit = 360; + if (args[arg] > limit) { + String argName = ""; + switch (arg) { case 1: argName = "pX"; break; case 2: argName = "pY"; break; case 3: argName = "pZ"; break; } + Logger.logError(mClient, LOG_TAG, "The sixel non-basic color command " + argName + " value " + args[arg] + " is not between 0-" + limit + " at index " + index + " of sixel input: " + dcs); + isValidDcs = false; + break; + } + } + } else if (ch == ';') { + if (arg == 3) { // Pz must not end with a ';'. + Logger.logError(mClient, LOG_TAG, "The sixel non-basic color command Pz value " + args[3] + " must not end with a semicolon ';' at index " + index + " of sixel input: " + dcs); + isValidDcs = false; + break; + } + + incArg = true; + } else { + break; + } + index++; + } + + if (isValidDcs && arg == 3) { // If complete spec is received and is valid. + if (args[0] == 2) { // Only RGB is supported. + mScreen.sixelSetColor(color, args[1], args[2], args[3]); + } + } else { + if (isValidDcs) + Logger.logError(mClient, LOG_TAG, "The sixel non-basic color command expected 4 arguments at index " + index + " of sixel input: " + dcs); + isValidDcs = false; + break; + } + } + } + // Graphics Repeat Introducer `! Pn character`. + // - https://vt100.net/docs/vt3xx-gp/chapter14.html#S14.3.1 + else if (ch == '!') { + index++; // Consume '!'. + + repeat = 0; + while (index < dcs.length()) { + ch = dcs.charAt(index); + if (ch >= '0' && ch <= '9') { + repeat = repeat * 10 + ch - '0'; + if (repeat > TerminalSixel.SIXEL__MAX_REPEAT) { + Logger.logError(mClient, LOG_TAG, "The sixel repeat command Pn value " + repeat + " is greater than max repeat value " + + TerminalSixel.SIXEL__MAX_REPEAT + " at index " + index + " of sixel input: " + dcs); + isValidDcs = false; + break; + } + index++; + } else { + break; + } + } + + if (!isValidDcs) { + break; + } + } + // Raster Attributes `" Pan; Pad; Ph; Pv` + // - https://vt100.net/docs/vt3xx-gp/chapter14.html#S14.3.2 + else if (ch == '"') { + index++; // Consume '"'. + + int[] args = {0, 0, 0, 0}; + int arg = 0; boolean incArg = false; + while (index < dcs.length()) { + ch = dcs.charAt(index); + if (ch >= '0' && ch <= '9') { + if (incArg) { arg++; incArg = false; } + args[arg] = args[arg] * 10 + ch - '0'; + if (args[arg] < 0) { // Overflow. + String argName = ""; + switch (arg) { case 0: argName = "Pan"; break; case 1: argName = "Pad"; break; case 2: argName = "pH"; break; case 3: argName = "pV"; break; } + Logger.logError(mClient, LOG_TAG, "The sixel raster command " + argName + "value overflow at index " + index + " of sixel input: " + dcs); + isValidDcs = false; + break; + } + } else if (ch == ';') { + if (arg == 3) { // Pv must not end with a ';'. + Logger.logError(mClient, LOG_TAG, "The sixel raster command Pv value " + args[3] + " must not end with a semicolon ';' at index " + index + " of sixel input: " + dcs); + isValidDcs = false; + break; + } + incArg = true; + } else { + break; + } + index++; + } + + if (isValidDcs && arg == 3) { // If complete spec is received and is valid. + // Raster pixel aspect ratio is not supported currently. + // Raster "rough" horizontal and vertical size of image may be sent at start of + // sixel data string, like done by `img2sixel`, so increase sixel commands args + // buffer capacity (`mTerminalControlArgs`) and resize sixel bitmap in + // `TerminalSixel` at start, instead of having to keep resizing buffer/bitmap + // as more sixel data is received, which has a performance hit due to + // memory reallocations and copying. + int sixelWidth = args[2]; // `Ph` + int sixelHeight = args[3]; // `Pv` + if (sixelWidth > 0 && sixelHeight > 0) { + // 2% extra for sixel commands/parameters in addition to image data. + int sixelArgsExpectedLength = (int) (sixelWidth * sixelHeight * 1.02); + // If sixel commands are too long, they are divided into parts, and if a + // new command starts near `TERMINAL_CONTROL_ARGS__MAX_LENGTH / 2`, it could + // contain image data for 1 pixel line of image width, so add that. + int sixelArgsPartsExpectedLength = (int) ((((double) TERMINAL_CONTROL_ARGS__MAX_LENGTH / 2) + sixelWidth) * 1.02); + int sixelArgsExpectedCapacity = Math.min(sixelArgsPartsExpectedLength, sixelArgsExpectedLength); + if (sixelArgsExpectedCapacity > SIXEL_ARGS__INITIAL_CAPACITY) { + mSixelArgsCapacity = sixelArgsExpectedCapacity; + } + + mScreen.sixelResize(sixelWidth, sixelHeight); + } + } else { + if (isValidDcs) + Logger.logError(mClient, LOG_TAG, "The sixel raster command expected 4 arguments at index " + index + " of sixel input: " + dcs); + isValidDcs = false; + break; + } + } + else if (ch == ' ' || ch == '\t' || ch == '\n' || ch == '\f' || ch == '\r') { + index++; + } else { + // Invalid character. + Logger.logError(mClient, LOG_TAG, "Invalid character '" + ch + "' (" + (byte) ch + ") at index " + index + " of sixel input: " + dcs); + isValidDcs = false; + break; + } } + + return isValidDcs; } + + private int nextTabStop(int numTabs) { for (int i = mCursorCol + 1; i < mColumns; i++) if (mTabStop[i] && --numTabs == 0) return Math.min(i, mRightMargin); @@ -1472,8 +2029,9 @@ private void doEsc(int b) { case '0': // SS3, ignore. break; case 'P': // Device control string - mOSCOrDeviceControlArgs.setLength(0); - continueSequence(ESC_P); + clearTerminalControlArgs(); + clearDcsTypeVariables(); + continueSequence(ESC_DCS); break; case '[': continueSequence(ESC_CSI); @@ -1482,13 +2040,15 @@ private void doEsc(int b) { setDecsetinternalBit(DECSET_BIT_APPLICATION_KEYPAD, true); break; case ']': // OSC - mOSCOrDeviceControlArgs.setLength(0); + clearTerminalControlArgs(); + clearOscTypeVariables(); continueSequence(ESC_OSC); break; case '>': // DECKPNM setDecsetinternalBit(DECSET_BIT_APPLICATION_KEYPAD, false); break; case '_': // APC - Application Program Command. + clearTerminalControlArgs(); continueSequence(ESC_APC); break; default: @@ -1717,7 +2277,7 @@ private void doCsi(int b) { // The important part that may still be used by some (tmux stores this value but does not currently use it) // is the first response parameter identifying the terminal service class, where we send 64 for "vt420". // This is followed by a list of attributes which is probably unused by applications. Send like xterm. - if (getArg0(0) == 0) mSession.write("\033[?64;1;2;6;9;15;18;21;22c"); + if (getArg0(0) == 0) mSession.write("\033[?64;1;2;4;6;9;15;18;21;22c"); break; case 'd': // ESC [ Pn d - Vert Position Absolute setCursorRow(Math.min(Math.max(1, getArg0(1)), mRows) - 1); @@ -1981,44 +2541,167 @@ private void selectGraphicRendition() { } } - private void doOsc(int b) { + + + /** + * Receive {@link #ESC_APC}. Check its docs for more info. + */ + private void receiveApc(final int b) { + switch (b) { + case 27: // Escape. + continueSequence(ESC_APC__ESC); + break; + default: + if (!collectTerminalControlArgs(b)) return; + } + } + + /** + * Receive {@link #ESC_APC__ESC}. Check its docs for more info. + */ + private void receiveApcEsc(final int b) { + switch (b) { + case '\\': + //doApc(); + //clearApcTypeVariables(); + break; + default: + // The ESC character was not followed by a \, so insert the ESC and + // the current character in arg buffer. + if (!collectTerminalControlArgs(27)) return; + if (!collectTerminalControlArgs(b)) return; + continueSequence(ESC_APC); + break; + } + } + + /** + * Clear {@link #ESC_APC} type variables. + */ + public void clearApcTypeVariables() {} + + /** + * Do {@link #ESC_APC}. Check its docs for more info. + */ + private void doApc() {} + + + + + /** + * Receive {@link #ESC_OSC}. Check its docs for more info. + */ + private void receiveOsc(final int b) { switch (b) { case 7: // Bell. - doOscSetTextParameters("\007"); + doOsc("\007"); + clearOscTypeVariables(); break; case 27: // Escape. - continueSequence(ESC_OSC_ESC); + continueSequence(ESC_OSC__ESC); break; default: - collectOSCArgs(b); + if (!collectTerminalControlArgs(b)) return; + if (mOscType == -1) { + setOscTypeVariables(); + } break; } } - private void doOscEsc(int b) { + /** + * Receive {@link #ESC_OSC__ESC}. Check its docs for more info. + */ + private void receiveOscEsc(final int b) { switch (b) { case '\\': - doOscSetTextParameters("\033\\"); + doOsc("\033\\"); + clearOscTypeVariables(); break; default: // The ESC character was not followed by a \, so insert the ESC and // the current character in arg buffer. - collectOSCArgs(27); - collectOSCArgs(b); + if (!collectTerminalControlArgs(27)) return; + if (!collectTerminalControlArgs(b)) return; continueSequence(ESC_OSC); break; } } - /** An Operating System Controls (OSC) Set Text Parameters. May come here from BEL or ST. */ - private void doOscSetTextParameters(String bellOrStringTerminator) { + /** + * Set {@link #ESC_OSC} type variables. + */ + void setOscTypeVariables() { + if (mOscType >= 0) return; + if (mTerminalControlArgs.indexOf(":") < 0) return; + + int value = -1; + int argsLength = mTerminalControlArgs.length(); + + // Extract initial $value from initial "$value;..." string. + for (int i = 0; i < argsLength; i++) { + char b = mTerminalControlArgs.charAt(i); + if (b == ';') { + mOscType = value; + break; + } else if (b >= '0' && b <= '9') { + value = ((value < 0) ? 0 : value * 10) + (b - '0'); + } else { + mOscType = -2; // Unknown sequence. + return; + } + } + + if (mOscType >= 0) { + Integer terminalControlArgsCapacity = null; + switch (mOscType) { + case 1337: // iTerm image command sends the base64 encoded image, do not run complex logic for each byte. + mIsFastPathOsc = true; + mIgnoreCrLfForOsc = true; + // Expect large amount of data for image bytes. + // `imgcat` utility splits image bytes into 200-byte chunks when sending with `FilePart=` commands. + // - https://github.com/gnachman/iTerm2-shell-integration/blob/d1d4012068c3c6761d5676c28ed73e0e2df2b715/utilities/imgcat#L89 + // > Older versions of tmux have a limit of 256 bytes for the entire sequence. + // - https://iterm2.com/documentation-images.html + terminalControlArgsCapacity = 256; + break; + } + + if (terminalControlArgsCapacity != null) { + ensureTerminalControlArgsCapacity(terminalControlArgsCapacity); + } + } + } + + /** + * Clear {@link #ESC_OSC} type variables. + */ + public void clearOscTypeVariables() { + mOscType = -1; + mIsFastPathOsc = false; + mIgnoreCrLfForOsc = false; + } + + /** + * Do {@link #ESC_OSC}. Check its docs for more info. + * + * This handles Set Text Parameters commands. + * + * The `bellOrStringTerminator` defines whether `OSC` command terminated with a `BEL` or `ST`. + */ + private void doOsc(String bellOrStringTerminator) { int value = -1; String textParameter = ""; + int argsLength = mTerminalControlArgs.length(); + // Extract initial $value from initial "$value;..." string. - for (int mOSCArgTokenizerIndex = 0; mOSCArgTokenizerIndex < mOSCOrDeviceControlArgs.length(); mOSCArgTokenizerIndex++) { - char b = mOSCOrDeviceControlArgs.charAt(mOSCArgTokenizerIndex); + for (int i = 0; i < argsLength; i++) { + char b = mTerminalControlArgs.charAt(i); if (b == ';') { - textParameter = mOSCOrDeviceControlArgs.substring(mOSCArgTokenizerIndex + 1); + // Do not make a copy of `mTerminalControlArgs` for lengthy commands. + if (value != 1337) { + textParameter = mTerminalControlArgs.substring(i + 1); + } break; } else if (b >= '0' && b <= '9') { value = ((value < 0) ? 0 : value * 10) + (b - '0'); @@ -2107,10 +2790,10 @@ private void doOscSetTextParameters(String bellOrStringTerminator) { case 52: // Manipulate Selection Data. Skip the optional first selection parameter(s). int startIndex = textParameter.indexOf(";") + 1; try { - String clipboardText = new String(Base64.decode(textParameter.substring(startIndex), 0), StandardCharsets.UTF_8); + String clipboardText = new String(Base64.decode(textParameter.substring(startIndex), Base64.DEFAULT), StandardCharsets.UTF_8); mSession.onCopyTextToClipboard(clipboardText); } catch (Exception e) { - Logger.logError(mClient, LOG_TAG, "OSC Manipulate selection, invalid string '" + textParameter + ""); + Logger.logError(mClient, LOG_TAG, "OSC Manipulate selection, invalid string '" + textParameter + "'"); } break; case 104: @@ -2148,10 +2831,126 @@ private void doOscSetTextParameters(String bellOrStringTerminator) { break; case 119: // Reset highlight color. break; + case 1337: // iTerm image + // - https://iterm2.com/documentation-images.html + // - https://iterm2.com/documentation-escape-codes.html + String controlCommandPrefix = mTerminalControlArgs.substring(5, Math.min(19, argsLength)); + + if (controlCommandPrefix.startsWith("File=") || + controlCommandPrefix.startsWith("MultipartFile=") || + controlCommandPrefix.startsWith("FilePart=") || + controlCommandPrefix.equals("FileEnd")) { + + ITermImage iTermImage = null; + boolean oscArgsCleared = false; + int index; + // `File = [optional arguments] : base-64 encoded file contents ^G` + if (controlCommandPrefix.startsWith("File=")) { + if (mITermImage != null) { + Logger.logWarn(mClient, LOG_TAG, "A new iTerm 'File' command received while already processing a 'MultipartFile' command"); + mITermImage = null; // Unset old image. + } + + iTermImage = new ITermImage(mClient, /* multiPart */ false); + if ((index = iTermImage.readArguments(this, mTerminalControlArgs, /* `1337;File=` */ 10)) < 10 || + !iTermImage.readImage(mTerminalControlArgs, index)) { + iTermImage = null; + } else { + // Free image data from memory held in osc command arguments as it is no longer needed. + clearTerminalControlArgs(); + oscArgsCleared = true; + if (!iTermImage.decodeImage()) { + iTermImage = null; + } + } + } + // `MultipartFile = [optional arguments] ^G` + else if (controlCommandPrefix.startsWith("MultipartFile=")) { + if (mITermImage != null) { + Logger.logWarn(mClient, LOG_TAG, "A new iTerm 'MultipartFile' command received while already processing a 'MultipartFile' command"); + mITermImage = null; // Unset old image. + } + + iTermImage = new ITermImage(mClient, /* multiPart */ true); + if (iTermImage.readArguments(this, mTerminalControlArgs, /* `1337;MultipartFile=` */ 19) < 19) { + iTermImage = null; + } else { + mITermImage = iTermImage; + } + } + // `FilePart = base64 encoded file contents ^G` + else if (controlCommandPrefix.startsWith("FilePart=")) { + if (mITermImage == null) { + Logger.logError(mClient, LOG_TAG, "An iTerm 'FilePart' command received without a 'MultipartFile' command preceding it"); + return; + } + + if (!mITermImage.readImage(mTerminalControlArgs, /* `1337;FilePart=` */ 14)) { + mITermImage = null; + } + } + // `FileEnd ^G` + else if (controlCommandPrefix.equals("FileEnd")) { + if (mITermImage == null) { + Logger.logError(mClient, LOG_TAG, "An iTerm 'FileEnd' command received without a 'MultipartFile' command preceding it"); + return; + } + + iTermImage = mITermImage; + mITermImage = null; // Free global reference so that memory is freed at end function. + if ( + !iTermImage.setMultiPartImageRead() || + !iTermImage.decodeImage()) { + iTermImage = null; + } + } + + // Free image data from memory held in osc command arguments as it is no longer needed. + if (!oscArgsCleared) + clearTerminalControlArgs(); + + if (iTermImage != null && iTermImage.isImageDecoded()) { + // Display image as inline in Terminal. + if (iTermImage.isInline()) { + int[] cursorDelta = mScreen.addTerminalBitmapForImage(iTermImage.getDecodedImage(), + mCursorCol, mCursorRow, mCellWidthPixels, mCellHeightPixels, + iTermImage.getWidth(), iTermImage.getHeight(), + iTermImage.shouldPreserveAspectRatio()); + + int col = cursorDelta[1] + mCursorCol; + if (col < mColumns - 1) { + cursorDelta[0] -= 1; + } else { + col = 0; + } + for (; cursorDelta[0] > 0; cursorDelta[0]--) { + doLinefeed(); + } + mCursorCol = col; + } + // Saving files in downloads folder is not supported currently. + else {} + } + break; + } else if (controlCommandPrefix.startsWith("ReportCellSize")) { + mSession.write(String.format(Locale.ENGLISH, "\0331337;ReportCellSize=%d;%d\007", mCellHeightPixels, mCellWidthPixels)); + } + + // Free image from memory for any non `MultipartFile=` related commands. + mITermImage = null; default: unknownParameter(value); break; } + + // Free image from memory if an incomplete `MultipartFile` command was received without a `FileEnd`. + // The `mITermImage` cannot set to `null` in `clearOscTypeVariables()` as sequential + // OSC commands will be received for `MultipartFile` commands, and the variable is required + // to be set until the final `FileEnd` command is received. + if (mITermImage != null && value != 1337) { + mITermImage = null; + } + finishSequence(); } @@ -2282,15 +3081,77 @@ private int getArg(int index, int defaultValue, boolean treatZeroAsDefault) { return result; } - private void collectOSCArgs(int b) { - if (mOSCOrDeviceControlArgs.length() < MAX_OSC_STRING_LENGTH) { - mOSCOrDeviceControlArgs.appendCodePoint(b); - continueSequence(mEscapeState); + + + /** Collect code point in {@link #mTerminalControlArgs}. */ + private boolean collectTerminalControlArgs(int b) { + if (mTerminalControlArgs.length() < TERMINAL_CONTROL_ARGS__MAX_LENGTH) { + try { + // Appending can cause an increase in capacity and cause an OOM. + mTerminalControlArgs.appendCodePoint(b); + continueSequence(mEscapeState); + return true; + } catch (Throwable t) { + if (t instanceof OutOfMemoryError) System.gc(); + Logger.logError(mClient, LOG_TAG, "Terminal control args collect failed for" + + " char '" + (char) b + "' (numeric value=" + b + ") and" + + " args string '" + mTerminalControlArgs.substring(0, Math.min(16, mTerminalControlArgs.length())) + "...' with length " + mTerminalControlArgs.length() + + ": " + t.getMessage()); + } } else { - unknownSequence(b); + Logger.logError(mClient, LOG_TAG, "Terminal control args overflow for" + + " char '" + (char) b + "' (numeric value=" + b + ") and" + + " args string '" + mTerminalControlArgs.substring(0, Math.min(16, mTerminalControlArgs.length())) + "...' with length " + mTerminalControlArgs.length()); } + + clearTerminalControlArgs(); + finishSequence(); + return false; } + /** Clear {@link #mTerminalControlArgs}. */ + private void clearTerminalControlArgs() { + if (mTerminalControlArgs.capacity() <= TERMINAL_CONTROL_ARGS__INITIAL_CAPACITY) { + // Mark existing buffer as empty and reuse old array already allocated in + // `StringBuffer` for future commands if required. + mTerminalControlArgs.setLength(0); + } else { + // `setLength()` will only update internal length marker and not reduce internal array + // capacity, and to deallocate extra memory `trimToSize()` needs to be called, which + // creates another smaller array. + // So just allocate a new object with an array with required initial capacity directly + // instead of setting length to 0, then trimming to create a smaller array, then + // increasing capacity again by creating a new array with required initial capacity by + // calling `ensureCapacity()`. + mTerminalControlArgs = new StringBuilder(TERMINAL_CONTROL_ARGS__INITIAL_CAPACITY); + } + } + + /** + * Ensure enough capacity for {@link #mTerminalControlArgs} to prevent repeated reallocation of + * memory and copying as more data is received and appended, like with `append(char)`. + * + * The default capacity for {@link #mTerminalControlArgs} is defined by + * {@link #TERMINAL_CONTROL_ARGS__INITIAL_CAPACITY}. + * + * By default, if `StringBuilder` reaches capacity, it sets new capacity to `(oldCapacity * 2) + 2`. + * So if initial capacity is `16`, and data to be received is 1024 bytes, then 6 reallocations + * will be done, so command processors should + * - https://cs.android.com/android/platform/superproject/+/android-16.0.0_r1:libcore/ojluni/src/main/java/java/lang/AbstractStringBuilder.java;l=758 + * - https://cs.android.com/android/platform/superproject/+/android-16.0.0_r1:libcore/ojluni/src/main/java/java/lang/AbstractStringBuilder.java;l=183 + * - https://cs.android.com/android/platform/superproject/+/android-16.0.0_r1:libcore/ojluni/src/main/java/java/lang/AbstractStringBuilder.java;l=210 + * + * See also {@link StringBuilder#ensureCapacity(int)}. + * + * @param capacity The new capacity. + */ + private void ensureTerminalControlArgsCapacity(int capacity) { + mTerminalControlArgs.ensureCapacity(capacity); + } + + + + private void unimplementedSequence(int b) { logError("Unimplemented sequence char '" + (char) b + "' (U+" + String.format("%04x", b) + ")"); finishSequence(); @@ -2565,6 +3426,10 @@ public void reset() { mColors.reset(); mSession.onColorsChanged(); + + clearTerminalControlArgs(); + clearOscTypeVariables(); + mITermImage = null; } public String getSelectedText(int x1, int y1, int x2, int y2) { diff --git a/terminal-emulator/src/main/java/com/termux/terminal/TerminalRow.java b/terminal-emulator/src/main/java/com/termux/terminal/TerminalRow.java index d68dc32623..14de528a07 100644 --- a/terminal-emulator/src/main/java/com/termux/terminal/TerminalRow.java +++ b/terminal-emulator/src/main/java/com/termux/terminal/TerminalRow.java @@ -42,13 +42,15 @@ public final class TerminalRow { /** The text filling this terminal row. */ public char[] mText; /** The number of java chars used in {@link #mText}. */ - private short mSpaceUsed; + private int mSpaceUsed; /** If this row has been line wrapped due to text output at the end of line. */ boolean mLineWrap; /** The style bits of each cell in the row. See {@link TextStyle}. */ final long[] mStyle; /** If this row might contain chars with width != 1, used for deactivating fast path */ boolean mHasNonOneWidthOrSurrogateChars; + /** If this row has a {@link TerminalBitmap}. Used for performance only. */ + public boolean mHasTerminalBitmap; /** Construct a blank row (containing only whitespace, ' ') with a specified style. */ public TerminalRow(int columns, long style) { @@ -144,8 +146,9 @@ private boolean wideDisplayCharacterStartingAt(int column) { public void clear(long style) { Arrays.fill(mText, ' '); Arrays.fill(mStyle, style); - mSpaceUsed = (short) mColumns; + mSpaceUsed = mColumns; mHasNonOneWidthOrSurrogateChars = false; + mHasTerminalBitmap = false; } // https://github.com/steven676/Android-Terminal-Emulator/commit/9a47042620bec87617f0b4f5d50568535668fe26 @@ -155,6 +158,10 @@ public void setChar(int columnToSet, int codePoint, long style) { mStyle[columnToSet] = style; + if (!mHasTerminalBitmap && TextStyle.isTerminalBitmap(style)) { + mHasTerminalBitmap = true; + } + final int newCodePointDisplayWidth = WcWidth.width(codePoint); // Fast path when we don't have any chars with width != 1 @@ -256,7 +263,7 @@ public void setChar(int columnToSet, int codePoint, long style) { throw new IllegalArgumentException("Cannot put wide character in last column"); } else if (columnToSet == mColumns - 2) { // Truncate the line to the second part of this wide char: - mSpaceUsed = (short) newNextColumnIndex; + mSpaceUsed = newNextColumnIndex; } else { // Overwrite the contents of the next column, which mean we actually remove java characters. Due to the // check at the beginning of this method we know that we are not overwriting a wide char. diff --git a/terminal-emulator/src/main/java/com/termux/terminal/TerminalSixel.java b/terminal-emulator/src/main/java/com/termux/terminal/TerminalSixel.java new file mode 100644 index 0000000000..986b4ebaf2 --- /dev/null +++ b/terminal-emulator/src/main/java/com/termux/terminal/TerminalSixel.java @@ -0,0 +1,287 @@ +package com.termux.terminal; + +import android.graphics.Bitmap; + +/** + * A terminal sixel received via `DCS q s..s ST` or `DCS P1; P2; P3; q s..s ST`. + * + * **See Also:** + * - `TerminalEmulator.ESC_DCS__SIXEL` + * - https://vt100.net/docs/vt3xx-gp/chapter14.html + * - https://en.wikipedia.org/wiki/Sixel + * - https://www.digiater.nl/openvms/decus/vax90b1/krypton-nasa/all-about-sixels.text + */ +public class TerminalSixel { + + public static final String LOG_TAG = "TerminalSixel"; + + + + public static final int[] SIXEL__INITIAL_COLOR_MAP = { + 0xFF000000, 0xFF3333CC, 0xFFCC2323, 0xFF33CC33, 0xFFCC33CC, 0xFF33CCCC, 0xFFCCCC33, 0xFF777777, + 0xFF444444, 0xFF565699, 0xFF994444, 0xFF569956, 0xFF995699, 0xFF569999, 0xFF999956, 0xFFCCCCCC + }; + + /** + * A sixel is a group of six pixels in a vertical column. + */ + public static final int SIXEL__LINE_LEN = 6; + + /** + * The max pixel dimensions of a sixel image bitmap. + * + * Each pixel is stored on 4 bytes for a {@link Bitmap.Config#ARGB_8888} bitmap color config, + * so a 2048x2048 sixel image will take 16,777,216 bytes/16MB. + */ + public static final int SIXEL__MAX_BITMAP_DIMENSION = 2048; + + public static final int SIXEL__BITMAP_RESIZE_EXTRA = 100; + + /** + * The max value for the sixel Graphics Repeat Introducer. + * + * Each repeat creates a new sixel line of `1x6` pixels, where `6` is the {@link #SIXEL__LINE_LEN}. + * + * - https://vt100.net/docs/vt3xx-gp/chapter14.html#S14.3.1 + */ + public static final int SIXEL__MAX_REPEAT = SIXEL__MAX_BITMAP_DIMENSION; + + + + protected final TerminalSessionClient mClient; + + protected Bitmap mBitmap; + + protected int mWidth; + protected int mHeight; + + protected int mCurX; + protected int mCurY; + + protected final int[] mColorMap; + protected int mColor; + + + + protected TerminalSixel(TerminalSessionClient client, Bitmap bitmap) { + mClient = client; + + mBitmap = bitmap; + + mWidth = 0; + mHeight = 0; + + mCurX = 0; + mCurY = 0; + + mColorMap = new int[256]; + System.arraycopy(SIXEL__INITIAL_COLOR_MAP, 0, mColorMap, 0, 16); + mColor = mColorMap[0]; + } + + + + public static TerminalSixel build(TerminalSessionClient client, int bitmapWidth, int bitmapHeight) { + try { + + Bitmap bitmap = Bitmap.createBitmap(bitmapWidth, bitmapHeight, Bitmap.Config.ARGB_8888); + bitmap.eraseColor(0); + + return new TerminalSixel(client, bitmap); + } catch (Throwable t) { + if (t instanceof OutOfMemoryError) System.gc(); + Logger.logError(client, LOG_TAG, "Create sixel bitmap for width " + bitmapWidth + " with height " + bitmapHeight + " failed: " + t.getMessage()); + return null; + } + } + + + + public TerminalSessionClient getClient() { + return mClient; + } + + + public Bitmap getBitmap() { + return mBitmap; + } + + + public int getWidth() { + return mWidth; + } + + public int getHeight() { + return mHeight; + } + + + public int getCurX() { + return mCurX; + } + + public int getCurY() { + return mCurY; + } + + + public int[] getColorMap() { + return mColorMap; + } + + public int getColor() { + return mColor; + } + + + + public boolean readData(int codePoint, int repeat) { + if (mBitmap == null) { + return false; + } + + // Graphics Carriage Return `$`. + // > The $ (2/4) character indicates the end of the sixel line. The active position returns + // > to the left page border of the same sixel line. You can use this character to overprint lines. + // - https://vt100.net/docs/vt3xx-gp/chapter14.html#S14.3.4 + if (codePoint == '$') { + mCurX = 0; + return true; + } + + // Graphics New Line `-`. + // > The - (2/13) character indicates the end of a sixel line. The active position moves to + // > the left margin of the next sixel line. + // - https://vt100.net/docs/vt3xx-gp/chapter14.html#S14.3.5 + if (codePoint == '-') { + mCurX = 0; + mCurY += SIXEL__LINE_LEN; + return true; + } + + if (mBitmap.getWidth() < mCurX + repeat) { + int newBitmapWidth = mCurX + repeat + SIXEL__BITMAP_RESIZE_EXTRA; + + if (newBitmapWidth < 0) { + Logger.logError(mClient, LOG_TAG, "The new sixel bitmap width overflowed: " + mCurX + " (cursor x) + " + repeat + " (repeat) + " + SIXEL__BITMAP_RESIZE_EXTRA); + return false; + } + + if (newBitmapWidth > TerminalSixel.SIXEL__MAX_BITMAP_DIMENSION) { + Logger.logError(mClient, LOG_TAG, "The new sixel bitmap width " + newBitmapWidth + " is greater than max bitmap dimension " + TerminalSixel.SIXEL__MAX_BITMAP_DIMENSION); + return false; + } + + mBitmap = TerminalBitmap.resizeBitmap(LOG_TAG, "sixel", mClient, mBitmap, newBitmapWidth, mBitmap.getHeight()); + if (mBitmap == null) { + return false; + } + } + + if (mBitmap.getHeight() < mCurY + SIXEL__LINE_LEN) { + // Very unlikely to resize both at the same time. + int newBitmapHeight = mCurY + SIXEL__BITMAP_RESIZE_EXTRA; + + if (newBitmapHeight < 0) { + Logger.logError(mClient, LOG_TAG, "The new sixel bitmap height overflowed: " + mCurY + " (cursor y) + " + SIXEL__BITMAP_RESIZE_EXTRA); + return false; + } + + if (newBitmapHeight > TerminalSixel.SIXEL__MAX_BITMAP_DIMENSION) { + Logger.logError(mClient, LOG_TAG, "The new sixel bitmap height " + newBitmapHeight + " is greater than max bitmap dimension " + TerminalSixel.SIXEL__MAX_BITMAP_DIMENSION); + return false; + } + + mBitmap = TerminalBitmap.resizeBitmap(LOG_TAG, "sixel", mClient, mBitmap, mBitmap.getWidth(), newBitmapHeight); + if (mBitmap == null) { + return false; + } + } + + if (mCurX + repeat > mBitmap.getWidth()) { + repeat = mBitmap.getWidth() - mCurX; + } + + if (mCurY + SIXEL__LINE_LEN > mBitmap.getHeight()) { + Logger.logError(mClient, LOG_TAG, "The sixel curson y position " + mCurY + SIXEL__LINE_LEN + " is greater than bitmap height " + mBitmap.getHeight()); + return false; + } + + // Sixel data characters are in the range of `?` (0x3F) to `~` (0x7E). + // > Each sixel data character represents six vertical pixels of data. Each sixel data character + // > represents a binary value equal to the character code value minus hex 3F. + // - https://vt100.net/docs/vt3xx-gp/chapter14.html#S14.2.1 + if (repeat > 0 && codePoint >= '?' && codePoint <= '~') { + int b = codePoint - '?'; + if (mCurY + SIXEL__LINE_LEN > mHeight) { + mHeight = mCurY + SIXEL__LINE_LEN; + } + + while (repeat-- > 0) { + for (int i = 0; i < SIXEL__LINE_LEN; i++) { + if ((b & (1 << i)) != 0) { + mBitmap.setPixel(mCurX, mCurY + i, mColor); + } + } + + mCurX += 1; + if (mCurX > mWidth) { + mWidth = mCurX; + } + } + } + + return true; + } + + public boolean resize(int sixelWidth, int sixelHeight) { + if (mBitmap == null) { + return false; + } + + if (sixelWidth < 1 || sixelHeight < 1) + return false; + + int bitmapWidth = mBitmap.getWidth(); + int newBitmapWidth = Math.max(sixelWidth, bitmapWidth); + + int bitmapHeight = mBitmap.getHeight(); + int newBitmapHeight = Math.max(sixelHeight, bitmapHeight); + + if (bitmapWidth < newBitmapWidth || bitmapHeight < newBitmapHeight) { + if (newBitmapWidth > TerminalSixel.SIXEL__MAX_BITMAP_DIMENSION) { + Logger.logError(mClient, LOG_TAG, "The new sixel bitmap resize width " + newBitmapWidth + " is greater than max bitmap dimension " + TerminalSixel.SIXEL__MAX_BITMAP_DIMENSION); + return false; + } + + if (newBitmapHeight > TerminalSixel.SIXEL__MAX_BITMAP_DIMENSION) { + Logger.logError(mClient, LOG_TAG, "The new sixel bitmap resize height " + newBitmapHeight + " is greater than max bitmap dimension " + TerminalSixel.SIXEL__MAX_BITMAP_DIMENSION); + return false; + } + + mBitmap = TerminalBitmap.resizeBitmap(LOG_TAG, "sixel", mClient, mBitmap, newBitmapWidth, newBitmapHeight); + if (mBitmap == null) { + return false; + } + } + + return true; + } + + public void setColor(int color) { + if (color >= 0 && color < mColorMap.length) { + mColor = mColorMap[color]; + } + } + + public void setRGBColor(int color, int r, int g, int b) { + if (color >= 0 && color < mColorMap.length) { + int red = Math.min(255, r * 255/100); + int green = Math.min(255, g * 255/100); + int blue = Math.min(255, b * 255/100); + mColor = 0xff000000 + (red << 16) + (green << 8) + blue; + mColorMap[color] = mColor; + } + } + +} diff --git a/terminal-emulator/src/main/java/com/termux/terminal/TextStyle.java b/terminal-emulator/src/main/java/com/termux/terminal/TextStyle.java index 173d6ae94e..008d1e777b 100644 --- a/terminal-emulator/src/main/java/com/termux/terminal/TextStyle.java +++ b/terminal-emulator/src/main/java/com/termux/terminal/TextStyle.java @@ -35,6 +35,8 @@ public final class TextStyle { private final static int CHARACTER_ATTRIBUTE_TRUECOLOR_FOREGROUND = 1 << 9; /** If true (24-bit) color is used for the cell for foreground. */ private final static int CHARACTER_ATTRIBUTE_TRUECOLOR_BACKGROUND= 1 << 10; + /** If true, character represents a {@link TerminalBitmap} slice, not text. */ + public final static int TERMINAL_BITMAP = 1 << 15; public final static int COLOR_INDEX_FOREGROUND = 256; public final static int COLOR_INDEX_BACKGROUND = 257; @@ -87,4 +89,28 @@ public static int decodeEffect(long style) { return (int) (style & 0b11111111111); } + + + public static long encodeTerminalBitmap(int num, int x, int y) { + return ((long) x << 48) | ((long) y << 32) | ((long) num << 16) | TERMINAL_BITMAP; + } + + public static boolean isTerminalBitmap(long style) { + return (style & TERMINAL_BITMAP) != 0; + } + + /* The bitmap num, x or y could have value `0`, so only return value (especially `0`) if bitmap bit is set. */ + + public static int getTerminalBitmapNum(long style) { + return (style & TERMINAL_BITMAP) != 0 ? (int) (style & 0xffff0000L) >> 16 : -1; + } + + public static int getTerminalBitmapX(long style) { + return (style & TERMINAL_BITMAP) != 0 ? (int) ((style >> 48) & 0xfff) : -1; + } + + public static int getTerminalBitmapY(long style) { + return (style & TERMINAL_BITMAP) != 0 ? (int) ((style >> 32) & 0xfff) : -1; + } + } diff --git a/terminal-view/src/main/java/com/termux/view/TerminalRenderer.java b/terminal-view/src/main/java/com/termux/view/TerminalRenderer.java index a4bef7d37c..787902ce08 100644 --- a/terminal-view/src/main/java/com/termux/view/TerminalRenderer.java +++ b/terminal-view/src/main/java/com/termux/view/TerminalRenderer.java @@ -1,9 +1,13 @@ package com.termux.view; +import android.graphics.Bitmap; import android.graphics.Canvas; import android.graphics.Paint; import android.graphics.PorterDuff; +import android.graphics.Rect; +import android.graphics.RectF; import android.graphics.Typeface; +import android.os.Build; import com.termux.terminal.TerminalBuffer; import com.termux.terminal.TerminalEmulator; @@ -98,10 +102,29 @@ public final void render(TerminalEmulator mEmulator, Canvas canvas, int topRow, final boolean charIsHighsurrogate = Character.isHighSurrogate(charAtIndex); final int charsForCodePoint = charIsHighsurrogate ? 2 : 1; final int codePoint = charIsHighsurrogate ? Character.toCodePoint(charAtIndex, line[currentCharIndex + 1]) : charAtIndex; + final long style = lineObject.getStyle(column); + if (TextStyle.isTerminalBitmap(style)) { + Bitmap bitmap = mEmulator.getScreen().getSixelBitmap(style); + if (bitmap != null) { + float left = column * mFontWidth; + float top = heightOffset - mFontLineSpacing; + Rect bitmapSrcRect = mEmulator.getScreen().getSixelRect(style); + RectF bitmapDestRect = new RectF(left, top, left + mFontWidth, top + mFontLineSpacing); + canvas.drawBitmap(bitmap, bitmapSrcRect, bitmapDestRect, null); + } + column += 1; + measuredWidthForRun = 0.f; + lastRunStyle = 0; + lastRunInsideCursor = false; + lastRunStartColumn = column + 1; + lastRunStartIndex = currentCharIndex; + lastRunFontWidthMismatch = false; + currentCharIndex += charsForCodePoint; + continue; + } final int codePointWcWidth = WcWidth.width(codePoint); final boolean insideCursor = (cursorX == column || (codePointWcWidth == 2 && cursorX == column + 1)); final boolean insideSelection = column >= selx1 && column <= selx2; - final long style = lineObject.getStyle(column); // Check if the measured text width for this code point is not the same as that expected by wcwidth(). // This could happen for some fonts which are not truly monospace, or for more exotic characters such as @@ -112,7 +135,7 @@ public final void render(TerminalEmulator mEmulator, Canvas canvas, int topRow, final boolean fontWidthMismatch = Math.abs(measuredCodePointWidth / mFontWidth - codePointWcWidth) > 0.01; if (style != lastRunStyle || insideCursor != lastRunInsideCursor || insideSelection != lastRunInsideSelection || fontWidthMismatch || lastRunFontWidthMismatch) { - if (column == 0) { + if (column == 0 || column == lastRunStartColumn) { // Skip first column as there is nothing to draw, just record the current style. } else { final int columnWidthSinceLastRun = column - lastRunStartColumn; @@ -208,8 +231,8 @@ private void drawTextRun(Canvas canvas, char[] text, int[] palette, float y, int if (cursor != 0) { mTextPaint.setColor(cursor); float cursorHeight = mFontLineSpacingAndAscent - mFontAscent; - if (cursorStyle == TerminalEmulator.TERMINAL_CURSOR_STYLE_UNDERLINE) cursorHeight /= 4.; - else if (cursorStyle == TerminalEmulator.TERMINAL_CURSOR_STYLE_BAR) right -= ((right - left) * 3) / 4.; + if (cursorStyle == TerminalEmulator.TERMINAL_CURSOR_STYLE_UNDERLINE) cursorHeight /= 4.f; + else if (cursorStyle == TerminalEmulator.TERMINAL_CURSOR_STYLE_BAR) right -= (((right - left) * 3) / 4.f); canvas.drawRect(left, y - cursorHeight, right, y, mTextPaint); } @@ -233,7 +256,11 @@ private void drawTextRun(Canvas canvas, char[] text, int[] palette, float y, int mTextPaint.setColor(foreColor); // The text alignment is the default Paint.Align.LEFT. - canvas.drawTextRun(text, startCharIndex, runWidthChars, startCharIndex, runWidthChars, left, y - mFontLineSpacingAndAscent, false, mTextPaint); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + canvas.drawTextRun(text, startCharIndex, runWidthChars, startCharIndex, runWidthChars, left, y - mFontLineSpacingAndAscent, false, mTextPaint); + } else { + canvas.drawText(text, startCharIndex, runWidthChars, left, y - mFontLineSpacingAndAscent, mTextPaint); + } } if (savedMatrix) canvas.restore();