From 18495cd1bc1c7c4f9f7e1ae6bcd39c74254adbb8 Mon Sep 17 00:00:00 2001 From: Matan Ziv-Av Date: Sat, 3 Sep 2022 13:58:33 +0300 Subject: [PATCH 01/11] Fixed: Clear LineWrap flag when clearing lines using blockSet --- .../src/main/java/com/termux/terminal/TerminalBuffer.java | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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..796afa0ba4 100644 --- a/terminal-emulator/src/main/java/com/termux/terminal/TerminalBuffer.java +++ b/terminal-emulator/src/main/java/com/termux/terminal/TerminalBuffer.java @@ -439,9 +439,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) { From 439b0b693f7c2ce90677326c31724763f1ce3e13 Mon Sep 17 00:00:00 2001 From: agnostic-apollo Date: Tue, 31 Mar 2026 00:40:38 +0500 Subject: [PATCH 02/11] Fixed: Revert using `Canvas.drawText()` instead of `Canvas.drawTextRun` only on Android 5 as later function is not available on it and will cause a runtime crash The change was done in 6c00f1fc61 via #2997 --- .../src/main/java/com/termux/view/TerminalRenderer.java | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) 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..5d29687fb3 100644 --- a/terminal-view/src/main/java/com/termux/view/TerminalRenderer.java +++ b/terminal-view/src/main/java/com/termux/view/TerminalRenderer.java @@ -4,6 +4,7 @@ import android.graphics.Paint; import android.graphics.PorterDuff; import android.graphics.Typeface; +import android.os.Build; import com.termux.terminal.TerminalBuffer; import com.termux.terminal.TerminalEmulator; @@ -233,7 +234,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(); From bdc3d72f8faa40a4acd7d2153fb8a1c1ebfdff2b Mon Sep 17 00:00:00 2001 From: agnostic-apollo Date: Tue, 31 Mar 2026 00:53:26 +0500 Subject: [PATCH 03/11] Fixed: Explicitly cast cursor dimensions to float to prevent loses --- .../src/main/java/com/termux/view/TerminalRenderer.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 5d29687fb3..214df331b3 100644 --- a/terminal-view/src/main/java/com/termux/view/TerminalRenderer.java +++ b/terminal-view/src/main/java/com/termux/view/TerminalRenderer.java @@ -209,8 +209,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); } From 4748e230accd7f94e462c61151f2a4aa8f826bd1 Mon Sep 17 00:00:00 2001 From: agnostic-apollo Date: Tue, 31 Mar 2026 22:27:47 +0500 Subject: [PATCH 04/11] Changed: Use `int` instead of `short` for space used for proper indexing --- .../src/main/java/com/termux/terminal/TerminalRow.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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..8d7e6bab20 100644 --- a/terminal-emulator/src/main/java/com/termux/terminal/TerminalRow.java +++ b/terminal-emulator/src/main/java/com/termux/terminal/TerminalRow.java @@ -42,7 +42,7 @@ 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}. */ @@ -144,7 +144,7 @@ private boolean wideDisplayCharacterStartingAt(int column) { public void clear(long style) { Arrays.fill(mText, ' '); Arrays.fill(mStyle, style); - mSpaceUsed = (short) mColumns; + mSpaceUsed = mColumns; mHasNonOneWidthOrSurrogateChars = false; } @@ -256,7 +256,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. From 7461709a832216b5aaecae86e273a959d6b9c58f Mon Sep 17 00:00:00 2001 From: agnostic-apollo Date: Thu, 9 Apr 2026 15:35:23 +0500 Subject: [PATCH 05/11] Fixed|Changed: Explicitly pass `Base64.DEFAULT` while base64 decoding text received for setting to clipboard from terminal and fix error format typo --- .../src/main/java/com/termux/terminal/TerminalEmulator.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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..d4023228c3 100644 --- a/terminal-emulator/src/main/java/com/termux/terminal/TerminalEmulator.java +++ b/terminal-emulator/src/main/java/com/termux/terminal/TerminalEmulator.java @@ -2107,10 +2107,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: From 948ffa41c87c4074ffc81eb3da99c37b9715c83d Mon Sep 17 00:00:00 2001 From: agnostic-apollo Date: Thu, 9 Apr 2026 03:19:07 +0500 Subject: [PATCH 06/11] Added|Changed|Fixed: Standardize function/variable names and add docs for OSC/DCS/APC in `TerminalEmulator`, add support for fast path for OSC/DCS terminal commands, and properly clear buffer after commands Rename `ESC_P` to `ESC_DCS`, `ESC_APC_ESCAPE` to `ESC_APC__ESC`, `doDeviceControl()` to `doDcs()`, `doOsc()` to `receiveOsc()`, `doOscEsc()` to `receiveOscEsc()`, `doApc()` to `receiveApc()`, `doApcEscape()` to `receiveApcEsc()`, `doOscSetTextParameters()` to `doOsc()`. `mOSCOrDeviceControlArgs` `StringBuilder` buffer has been renamed to `mTerminalControlArgs` as it stores args for all 3 OSC/DCS/APC commands. Additionally, for `mTerminalControlArgs`, only `setLength(0)` was called at end of commands to clear it, which 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 if an OSC/APC command was ever received with `MAX_OSC_STRING_LENGTH`, then the buffer size would never be brought back down to a lower capacity (default 16bytes), which would have slightly affected performance/memory for later smaller commands. That is fixed now by `clearTerminalControlArgs()` with a newer logic for freeing memory, which is required to free memory for long commands like sixels or iterm image to be added later. `ensureTerminalControlArgsCapacity()` has also been added to allow control commands to increase buffer capacity to reduce/prevent reallocation and copying of memory as more data is received. `collectTerminalControlArgs()` has been updated with better error message, catches OOM in case caused when buffer capacity is increased when appending, and returns `false` if an error occurred like an overflow/OOM so that callers can terminate processing. This was required because `receiveApcEsc()`/`receiveOscEsc()` functions appended `ESC`/`27` and then appended `b` without checking if `collectOSCArgs()` had already failed when collecting `ESC`, triggering a second error. `collectTerminalControlArgs()` is now also used for `DCS` commands properly handle errors/exceptions. `MAX_OSC_STRING_LENGTH` has been renamed to `TERMINAL_CONTROL_ARGS__MAX_LENGTH` and the length has been increased from `8192` to `16384` to receive larger sixel data and iTerm image data. With `16384`, around 12KB images can be received with legacy `File=` command, otherwise with `8192`, any image over 6KB would have triggered an overflow. Sixel commands strings can also be split into larger parts in future with the new limit. The `TerminalEmulator.processCodePoint()` processes bytes received by the terminal, however there are around ~30 case comparisons done before `ESC_OSC` or `ESC_DCS` is processed for each byte received. While it may be necessary for general commands, some commands like sixel or iterm image data parse their own input byte, which may be random data, so a fast path has been added at start of `processCodePoint()` to call `doOsc` or `doDcs()` if enabled by the currently running command. Fast path can be enabled for OSC commands via `setOscTypeVariables()` and for `DCS` commands via `doDcs()`. OSC commands can also prevent CR/LF characters being printed by enabling `mIgnoreCrLfForOsc` via `setOscTypeVariables()`. Escape states for Terminal APC commands fast path added in f35063da to `TerminalEmulator.processCodePoint()` has also been fixed. --- .../com/termux/terminal/TerminalEmulator.java | 486 +++++++++++++++--- 1 file changed, 403 insertions(+), 83 deletions(-) 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 d4023228c3..0eefd5d01e 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,60 @@ 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; + + /** * True if the current escape sequence should continue, false if the current escape sequence should be terminated. @@ -359,6 +492,8 @@ private int getTerminalTranscriptRows(Integer transcriptRows) { return transcriptRows; } + + /** * @param mouseButton one of the MOUSE_* constants of this class. */ @@ -568,12 +703,34 @@ private void processByte(byte byteToProcess) { } public void processCodePoint(int b) { + 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 +739,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; @@ -632,13 +789,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 +996,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,12 +1072,18 @@ 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(); + + + /** + * 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 == '\\') + ) { + String dcs = mTerminalControlArgs.toString(); + // DCS $ q P t ST. Request Status String (DECRQSS) if (dcs.startsWith("$q")) { if (dcs.equals("$q\"p")) { @@ -1022,43 +1186,21 @@ private void doDeviceControl(int b) { 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; - /** - * When in {@link #ESC_APC} (APC, Application Program Command) sequence. - */ - private void doApc(int b) { - if (b == 27) { - continueSequence(ESC_APC_ESCAPE); + if (!collectTerminalControlArgs(b)) return; } - // 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); - } + public void clearDcsTypeVariables() { + ESC_DCS__ESC = false; + mIsFastPathDcs = false; } private int nextTabStop(int numTabs) { @@ -1472,8 +1614,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 +1625,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: @@ -1981,44 +2126,154 @@ 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) { + } + + 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); + textParameter = mTerminalControlArgs.substring(i + 1); break; } else if (b >= '0' && b <= '9') { value = ((value < 0) ? 0 : value * 10) + (b - '0'); @@ -2282,15 +2537,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 +2882,9 @@ public void reset() { mColors.reset(); mSession.onColorsChanged(); + + clearTerminalControlArgs(); + clearOscTypeVariables(); } public String getSelectedText(int x1, int y1, int x2, int y2) { From a7f2872c6ce93cdc1a10e5e5ca2aad9030b78f8d Mon Sep 17 00:00:00 2001 From: Matan Ziv-Av Date: Thu, 9 Apr 2026 03:39:44 +0500 Subject: [PATCH 07/11] Add sixel support: - In TerminalEmulator, interpret sixel sequences, and send them to TerminalBuffer for constructing a bitmap. - Sixel sequences may be longer than 8192 characters, so break them in natural places ($,-,#), rather than collecting all in the buffer. - The bitmap is sliced to character cell sized slices, and each the the style attribute is used to store which bitmap slice is displayed in place of this character. - In TerminalRenderer the style is interpreted, and drawn using drawBitmap, instead of drawText. Support iTerm inline image protocol (OSC 1337): - Using the same bitmap display infrastructure introduced for sixels. - Collects the image data outside of the OSC buffer. - Ignoring some parameters. Small emulator changes: - Also eat APC sequences, not echoing to screen. - Fix `CSI 14 t` to give actual size - Add `CSI 16 t` - Add `4` (sixel) to device attributes --- .../com/termux/terminal/TerminalBitmap.java | 187 +++++++++++++ .../com/termux/terminal/TerminalBuffer.java | 126 +++++++++ .../com/termux/terminal/TerminalEmulator.java | 256 +++++++++++++++++- .../java/com/termux/terminal/TerminalRow.java | 7 + .../java/com/termux/terminal/TextStyle.java | 22 ++ .../terminal/WorkingTerminalBitmap.java | 110 ++++++++ .../com/termux/view/TerminalRenderer.java | 26 +- 7 files changed, 729 insertions(+), 5 deletions(-) create mode 100644 terminal-emulator/src/main/java/com/termux/terminal/TerminalBitmap.java create mode 100644 terminal-emulator/src/main/java/com/termux/terminal/WorkingTerminalBitmap.java 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..9758705bdc --- /dev/null +++ b/terminal-emulator/src/main/java/com/termux/terminal/TerminalBitmap.java @@ -0,0 +1,187 @@ +package com.termux.terminal; + +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; + +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +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 + * history. + *

    + * See {@link #externalToInternalRow(int)} for how to map from logical screen rows to array indices. + */ +public class TerminalBitmap { + public Bitmap bitmap; + public int cellWidth; + public int cellHeight; + public int scrollLines; + public int[] cursorDelta; + private static final String LOG_TAG = "TerminalBitmap"; + + + public TerminalBitmap(int num, WorkingTerminalBitmap sixel, int Y, int X, int cellW, int cellH, TerminalBuffer screen) { + Bitmap bm = sixel.bitmap; + bm = resizeBitmapConstraints(bm, sixel.width, sixel.height, cellW, cellH, screen.mColumns - X); + addBitmap(num, bm, Y, X, cellW, cellH, screen); + } + + public TerminalBitmap(int num, byte[] image, int Y, int X, int cellW, int cellH, int width, int height, boolean aspect, TerminalBuffer screen) { + Bitmap bm = null; + int imageHeight; + int imageWidth; + int newWidth = width; + int newHeight = height; + if (height > 0 || width > 0) { + BitmapFactory.Options options = new BitmapFactory.Options(); + options.inJustDecodeBounds = true; + try { + BitmapFactory.decodeByteArray(image, 0, image.length, options); + } catch (Exception e) { + Logger.logWarn(null, LOG_TAG, "Cannot decode image"); + } + imageHeight = options.outHeight; + imageWidth = options.outWidth; + if (aspect) { + 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; + } + BitmapFactory.Options scaleOptions = new BitmapFactory.Options(); + scaleOptions.inSampleSize = scaleFactor; + try { + bm = BitmapFactory.decodeByteArray(image, 0, image.length, scaleOptions); + } catch (Exception e) { + Logger.logWarn(null, LOG_TAG, "Out of memory, cannot decode image"); + bitmap = null; + return; + } + if (bm == null) { + Logger.logWarn(null, LOG_TAG, "Could not decode image"); + bitmap = null; + return; + } + int maxWidth = (screen.mColumns - X) * cellW; + if (newWidth > maxWidth) { + int cropWidth = bm.getWidth() * maxWidth / newWidth; + try { + bm = Bitmap.createBitmap(bm, 0, 0, cropWidth, bm.getHeight()); + newWidth = maxWidth; + } catch(OutOfMemoryError e) { + // This is just a memory optimization. If it fails, + // continue (and probably fail later). + } + } + try { + bm = Bitmap.createScaledBitmap(bm, newWidth, newHeight, true); + } catch(OutOfMemoryError e) { + Logger.logWarn(null, LOG_TAG, "Out of memory, cannot rescale image"); + bm = null; + } + } else { + try { + bm = BitmapFactory.decodeByteArray(image, 0, image.length); + } catch (OutOfMemoryError e) { + Logger.logWarn(null, LOG_TAG, "Out of memory, cannot decode image"); + } + } + + if (bm == null) { + Logger.logWarn(null, LOG_TAG, "Cannot decode image"); + bitmap = null; + return; + } + + bm = resizeBitmapConstraints(bm, bm.getWidth(), bm.getHeight(), cellW, cellH, screen.mColumns - X); + addBitmap(num, bm, Y, X, cellW, cellH, screen); + cursorDelta = new int[] {scrollLines, (bitmap.getWidth() + cellW - 1) / cellW}; + } + + private void addBitmap(int num, Bitmap bm, int Y, int X, int cellW, int cellH, TerminalBuffer screen) { + if (bm == null) { + bitmap = null; + return; + } + int width = bm.getWidth(); + int height = bm.getHeight(); + cellWidth = cellW; + cellHeight = cellH; + int w = Math.min(screen.mColumns - X, (width + cellW - 1) / cellW); + int h = (height + cellH - 1) / cellH; + int s = 0; + for (int i=0; i cellW * Columns || (w % cellW) != 0 || (h % cellH) != 0) { + int newW = Math.min(cellW * Columns, ((w - 1) / cellW) * cellW + cellW); + int newH = ((h - 1) / cellH) * cellH + cellH; + try { + bm = resizeBitmap(bm, newW, newH); + } catch(OutOfMemoryError e) { + // Only a minor display glitch in this case + } + } + return bm; + } +} 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 796afa0ba4..8e8620d393 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,15 @@ 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.BitmapFactory; +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 @@ -20,6 +29,12 @@ public final class TerminalBuffer { /** The index in the circular buffer where the visible screen starts. */ private int mScreenFirstRow = 0; + public HashMap bitmaps; + public WorkingTerminalBitmap workingBitmap; + private boolean hasBitmaps; + private long bitmapLastGC; + + /** * Create a transcript screen. * @@ -35,6 +50,9 @@ public TerminalBuffer(int columns, int totalRows, int screenRows) { mLines = new TerminalRow[totalRows]; blockSet(0, 0, columns, screenRows, ' ', TextStyle.NORMAL); + hasBitmaps = false; + bitmaps = new HashMap(); + bitmapLastGC = SystemClock.uptimeMillis(); } public String getTranscriptText() { @@ -401,6 +419,28 @@ public void scrollDownOneLine(int topMargin, int bottomMargin, long style) { if (mLines[blankRow] == null) { mLines[blankRow] = new TerminalRow(mColumns, style); } else { + // find if a bitmap is completely scrolled out + Set used = new HashSet(); + if(mLines[blankRow].mHasBitmap) { + for (int column = 0; column < mColumns; column++) { + final long st = mLines[blankRow].getStyle(column); + if (TextStyle.isBitmap(st)) { + used.add((int)(st >> 16) & 0xffff); + } + } + TerminalRow nextLine = mLines[(blankRow + 1) % mTotalRows]; + if(nextLine.mHasBitmap) { + for (int column = 0; column < mColumns; column++) { + final long st = nextLine.getStyle(column); + if (TextStyle.isBitmap(st)) { + used.remove((int)(st >> 16) & 0xffff); + } + } + } + for(Integer bm: used) { + bitmaps.remove(bm); + } + } mLines[blankRow].clear(style); } } @@ -496,6 +536,92 @@ public void clearTranscript() { Arrays.fill(mLines, mScreenFirstRow - mActiveTranscriptRows, mScreenFirstRow, null); } mActiveTranscriptRows = 0; + bitmaps.clear(); + hasBitmaps = false; + } + + public Bitmap getSixelBitmap(int codePoint, long style) { + return bitmaps.get(TextStyle.bitmapNum(style)).bitmap; + } + + public Rect getSixelRect(int codePoint, long style ) { + TerminalBitmap bm = bitmaps.get(TextStyle.bitmapNum(style)); + int x = TextStyle.bitmapX(style); + int y = TextStyle.bitmapY(style); + Rect r = new Rect(x * bm.cellWidth, y * bm.cellHeight, (x+1) * bm.cellWidth, (y+1) * bm.cellHeight); + return r; + } + + public void sixelStart(int width, int height) { + workingBitmap = new WorkingTerminalBitmap(width, height); + } + + public void sixelChar(int c, int rep) { + workingBitmap.sixelChar(c, rep); + } + + public void sixelSetColor(int col) { + workingBitmap.sixelSetColor(col); + } + + public void sixelSetColor(int col, int r, int g, int b) { + workingBitmap.sixelSetColor(col, r, g, b); + } + + private int findFreeBitmap() { + int i = 0; + while (bitmaps.containsKey(i)) { + i++; + } + return i; + } + + public int sixelEnd(int Y, int X, int cellW, int cellH) { + int num = findFreeBitmap(); + bitmaps.put(num, new TerminalBitmap(num, workingBitmap, Y, X, cellW, cellH, this)); + workingBitmap = null; + if (bitmaps.get(num).bitmap == null) { + bitmaps.remove(num); + return 0; + } + hasBitmaps = true; + bitmapGC(30000); + return bitmaps.get(num).scrollLines; + } + + public int[] addImage(byte[] image, int Y, int X, int cellW, int cellH, int width, int height, boolean aspect) { + int num = findFreeBitmap(); + bitmaps.put(num, new TerminalBitmap(num, image, Y, X, cellW, cellH, width, height, aspect, this)); + if (bitmaps.get(num).bitmap == null) { + bitmaps.remove(num); + return new int[] {0,0}; + } + hasBitmaps = true; + bitmapGC(30000); + return bitmaps.get(num).cursorDelta; } + public void bitmapGC(int timeDelta) { + if (!hasBitmaps || bitmapLastGC + timeDelta > SystemClock.uptimeMillis()) { + return; + } + Set used = new HashSet(); + for (int line = 0; line < mLines.length; line++) { + if(mLines[line] != null && mLines[line].mHasBitmap) { + for (int column = 0; column < mColumns; column++) { + final long st = mLines[line].getStyle(column); + if (TextStyle.isBitmap(st)) { + used.add((int)(st >> 16) & 0xffff); + } + } + } + } + Set keys = new HashSet(bitmaps.keySet()); + for (Integer bn: keys) { + if (!used.contains(bn)) { + bitmaps.remove(bn); + } + } + bitmapLastGC = 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 0eefd5d01e..1fe43023bc 100644 --- a/terminal-emulator/src/main/java/com/termux/terminal/TerminalEmulator.java +++ b/terminal-emulator/src/main/java/com/termux/terminal/TerminalEmulator.java @@ -3,6 +3,7 @@ import android.util.Base64; import java.nio.charset.StandardCharsets; +import java.util.ArrayList; import java.util.Arrays; import java.util.Locale; import java.util.Objects; @@ -330,6 +331,12 @@ public final class TerminalEmulator { /** The current state of the escape sequence state machine. One of the ESC_* constants. */ private int mEscapeState; + private boolean ESC_DCS_escape = false; + private boolean ESC_DCS_sixel = false; + private ArrayList ESC_OSC_data; + private int ESC_OSC_colon = 0; + private boolean ESC_OSC_outofmem = false; + private final SavedScreenState mSavedStateMain = new SavedScreenState(); private final SavedScreenState mSavedStateAlt = new SavedScreenState(); @@ -406,6 +413,13 @@ public final class TerminalEmulator { private static final String LOG_TAG = "TerminalEmulator"; + private int cellW = 12, cellH = 12; + + public void setCellSize(int w, int h) { + cellW = w; + cellH = h; + } + private boolean isDecsetInternalBitSet(int bit) { return (mCurrentDecSetFlags & bit) != 0; } @@ -734,6 +748,7 @@ public void processCodePoint(int b) { return; } + mScreen.bitmapGC(300000); switch (b) { case 0: // Null character (NUL, ^@). Do nothing. break; @@ -768,10 +783,16 @@ public void processCodePoint(int b) { case 10: // Line feed (LF, \n). case 11: // Vertical tab (VT, \v). case 12: // Form feed (FF, \f). - doLinefeed(); + if((mEscapeState != ESC_DCS || !ESC_DCS_sixel) && ESC_OSC_colon <= 0) { + // Ignore CR/LF inside sixels or iterm2 data + doLinefeed(); + } break; case 13: // Carriage return (CR, \r). - setCursorCol(mLeftMargin); + if((mEscapeState != ESC_DCS || !ESC_DCS_sixel) && ESC_OSC_colon <= 0) { + // Ignore CR/LF inside sixels or iterm2 data + setCursorCol(mLeftMargin); + } break; case 14: // Shift Out (Ctrl-N, SO) → Switch to Alternate Character Set. This invokes the G1 character set. mUseLineDrawingUsesG0 = false; @@ -1078,9 +1099,20 @@ public void processCodePoint(int b) { * Do {@link #ESC_DCS}. Check its docs for more info. */ private void doDcs(final int b) { + boolean firstSixel = false; + if (!ESC_DCS_sixel && (b=='$' || b=='-' || b=='#')) { + //Check if sixel sequence that needs breaking + String dcs = mTerminalControlArgs.toString(); + if (dcs.matches("[0-9;]*q.*")) { + firstSixel = true; + } + } + if ( // End of DCS if string terminator ST `ESC \` received. (ESC_DCS__ESC && b == '\\') + // Sixel sequences may be very long. '$' and '!' are natural for breaking the sequence. + || firstSixel || (ESC_DCS_sixel && (b=='$' || b=='-' || b=='#')) ) { String dcs = mTerminalControlArgs.toString(); @@ -1182,6 +1214,95 @@ private void doDcs(final int b) { Logger.logError(mClient, LOG_TAG, "Invalid device termcap/terminfo name of odd length: " + part); } } + } else if (ESC_DCS_sixel || dcs.matches("[0-9;]*q.*")) { + int pos = 0; + if (!ESC_DCS_sixel) { + ESC_DCS_sixel = true; + mScreen.sixelStart(100, 100); + while (dcs.codePointAt(pos) != 'q') { + pos++; + } + pos++; + } + if (b=='$' || b=='-') { + // Add to string + dcs = dcs + (char)b; + } + int rep = 1; + while (pos < dcs.length()) { + if (dcs.codePointAt(pos) == '"') { + pos++; + int args[]={0,0,0,0}; + int arg = 0; + while (pos < dcs.length() && ((dcs.codePointAt(pos) >= '0' && dcs.codePointAt(pos) <= '9') || dcs.codePointAt(pos) == ';')) { + if (dcs.codePointAt(pos) >= '0' && dcs.codePointAt(pos) <= '9') { + args[arg] = args[arg] * 10 + dcs.codePointAt(pos) - '0'; + } else { + arg++; + if (arg > 3) { + break; + } + } + pos++; + } + if (pos == dcs.length()) { + break; + } + } else if (dcs.codePointAt(pos) == '#') { + int col = 0; + pos++; + while (pos < dcs.length() && dcs.codePointAt(pos) >= '0' && dcs.codePointAt(pos) <= '9') { + col = col * 10 + dcs.codePointAt(pos++) - '0'; + } + if (pos == dcs.length() || dcs.codePointAt(pos) != ';') { + mScreen.sixelSetColor(col); + } else { + pos++; + int args[]={0,0,0,0}; + int arg = 0; + while (pos < dcs.length() && ((dcs.codePointAt(pos) >= '0' && dcs.codePointAt(pos) <= '9') || dcs.codePointAt(pos) == ';')) { + if (dcs.codePointAt(pos) >= '0' && dcs.codePointAt(pos) <= '9') { + args[arg] = args[arg] * 10 + dcs.codePointAt(pos) - '0'; + } else { + arg++; + if (arg > 3) { + break; + } + } + pos++; + } + if (args[0] == 2) { + mScreen.sixelSetColor(col, args[1], args[2], args[3]); + } + } + } else if (dcs.codePointAt(pos) == '!') { + rep = 0; + pos++; + while (pos < dcs.length() && dcs.codePointAt(pos) >= '0' && dcs.codePointAt(pos) <= '9') { + rep = rep * 10 + dcs.codePointAt(pos++) - '0'; + } + } else if (dcs.codePointAt(pos) == '$' || dcs.codePointAt(pos) == '-' || (dcs.codePointAt(pos) >= '?' && dcs.codePointAt(pos) <= '~')) { + mScreen.sixelChar(dcs.codePointAt(pos++), rep); + rep = 1; + } else { + pos++; + } + } + if (b == '\\') { + ESC_DCS_sixel = false; + int n = mScreen.sixelEnd(mCursorRow, mCursorCol, cellW, cellH); + for(;n>0;n--) { + doLinefeed(); + } + } else { + mTerminalControlArgs.setLength(0); + if (b=='#') { + mTerminalControlArgs.appendCodePoint(b); + } + // Do not finish sequence + continueSequence(mEscapeState); + return; + } } else { if (LOG_ESCAPE_SEQUENCES) Logger.logError(mClient, LOG_TAG, "Unrecognized device control string: " + dcs); @@ -1628,6 +1749,7 @@ private void doEsc(int b) { clearTerminalControlArgs(); clearOscTypeVariables(); continueSequence(ESC_OSC); + ESC_OSC_colon = -1; break; case '>': // DECKPNM setDecsetinternalBit(DECSET_BIT_APPLICATION_KEYPAD, false); @@ -1862,7 +1984,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); @@ -2190,6 +2312,29 @@ private void receiveOsc(final int b) { if (mOscType == -1) { setOscTypeVariables(); } + + if (ESC_OSC_colon == -1 && b == ':') { + // Collect base64 data for OSC 1337 + ESC_OSC_colon = mTerminalControlArgs.length(); + ESC_OSC_data = new ArrayList(65536); + ESC_OSC_outofmem = false; + } else if (ESC_OSC_colon >= 0 && mTerminalControlArgs.length() - ESC_OSC_colon == 4) { + if (!ESC_OSC_outofmem) { + try { + byte[] decoded = Base64.decode(mTerminalControlArgs.substring(ESC_OSC_colon), 0); + for (int i = 0 ; i < decoded.length; i++) { + ESC_OSC_data.add(decoded[i]); + } + } catch(Exception e) { + // Ignore non-Base64 data. + } catch(OutOfMemoryError e) { + // Out of memory + // Keep decoding, but fo not collect the data + ESC_OSC_outofmem = true; + } + } + mTerminalControlArgs.setLength(ESC_OSC_colon); + } break; } } @@ -2266,6 +2411,8 @@ public void clearOscTypeVariables() { */ private void doOsc(String bellOrStringTerminator) { int value = -1; + int osc_colon = ESC_OSC_colon; + ESC_OSC_colon = -1; String textParameter = ""; int argsLength = mTerminalControlArgs.length(); @@ -2403,6 +2550,105 @@ private void doOsc(String bellOrStringTerminator) { break; case 119: // Reset highlight color. break; + case 1337: // iTerm extemsions + if (textParameter.startsWith("File=")) { + int pos = 5; + boolean inline = false; + boolean aspect = true; + int width = -1; + int height = -1; + while (pos < textParameter.length()) { + int eqpos = textParameter.indexOf('=', pos); + if (eqpos == -1) { + break; + } + int semicolonpos = textParameter.indexOf(';', eqpos); + if (semicolonpos == -1) { + semicolonpos = textParameter.length() - 1; + } + String k = textParameter.substring(pos, eqpos); + String v = textParameter.substring(eqpos + 1, semicolonpos); + pos = semicolonpos + 1; + if (k.equalsIgnoreCase("inline")) { + inline = v.equals("1"); + } + if (k.equalsIgnoreCase("preserveAspectRatio")) { + aspect = ! v.equals("0"); + } + if (k.equalsIgnoreCase("width")) { + double factor = cellW; + int div = 1; + int e = v.length(); + if (v.endsWith("px")) { + factor = 1; + e -= 2; + } else if (v.endsWith("%")) { + factor = 0.01 * cellW * mColumns; + e -= 1; + } + try { + width = (int)(factor * Integer.parseInt(v.substring(0,e))); + } catch(Exception ex) { + } + } + if (k.equalsIgnoreCase("height")) { + double factor = cellH; + int div = 1; + int e = v.length(); + if (v.endsWith("px")) { + factor = 1; + e -= 2; + } else if (v.endsWith("%")) { + factor = 0.01 * cellH * mRows; + e -= 1; + } + try { + height = (int)(factor * Integer.parseInt(v.substring(0,e))); + } catch(Exception ex) { + } + } + } + if (!inline) { + finishSequence(); + return; + } + if (osc_colon >= 0 && mTerminalControlArgs.length() > osc_colon) { + while (mTerminalControlArgs.length() - osc_colon < 4) { + mTerminalControlArgs.append('='); + } + try { + byte[] decoded = Base64.decode(mTerminalControlArgs.substring(osc_colon), 0); + for (int i = 0 ; i < decoded.length; i++) { + ESC_OSC_data.add(decoded[i]); + } + } catch(Exception e) { + // Ignore non-Base64 data. + } + mTerminalControlArgs.setLength(osc_colon); + } + if (osc_colon >= 0) { + byte[] result = new byte[ESC_OSC_data.size()]; + for(int i = 0; i < ESC_OSC_data.size(); i++) { + result[i] = ESC_OSC_data.get(i).byteValue(); + } + int[] res = mScreen.addImage(result, mCursorRow, mCursorCol, cellW, cellH, width, height, aspect); + int col = res[1] + mCursorCol; + if (col < mColumns -1) { + res[0] -= 1; + } else { + col = 0; + } + for(;res[0] > 0; res[0]--) { + doLinefeed(); + } + mCursorCol = col; + ESC_OSC_data.clear(); + } else { + } + } else if (textParameter.startsWith("ReportCellSize")) { + mSession.write(String.format(Locale.US, "\0331337;ReportCellSize=%d;%d\007", cellH, cellW)); + } + break; default: unknownParameter(value); break; @@ -2883,6 +3129,10 @@ public void reset() { mColors.reset(); mSession.onColorsChanged(); + ESC_DCS_escape = false; + ESC_DCS_sixel = false; + ESC_OSC_colon = -1; + clearTerminalControlArgs(); clearOscTypeVariables(); } 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 8d7e6bab20..a70103482f 100644 --- a/terminal-emulator/src/main/java/com/termux/terminal/TerminalRow.java +++ b/terminal-emulator/src/main/java/com/termux/terminal/TerminalRow.java @@ -49,6 +49,8 @@ public final class TerminalRow { final long[] mStyle; /** If this row might contain chars with width != 1, used for deactivating fast path */ boolean mHasNonOneWidthOrSurrogateChars; + /** If this row has a bitmap. Used for performace only */ + public boolean mHasBitmap; /** Construct a blank row (containing only whitespace, ' ') with a specified style. */ public TerminalRow(int columns, long style) { @@ -146,6 +148,7 @@ public void clear(long style) { Arrays.fill(mStyle, style); mSpaceUsed = mColumns; mHasNonOneWidthOrSurrogateChars = false; + mHasBitmap = 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 (!mHasBitmap && TextStyle.isBitmap(style)) { + mHasBitmap = true; + } + final int newCodePointDisplayWidth = WcWidth.width(codePoint); // Fast path when we don't have any chars with width != 1 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..7ee4b06ebc 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 bitmap slice, not text. */ + public final static int BITMAP = 1 << 15; public final static int COLOR_INDEX_FOREGROUND = 256; public final static int COLOR_INDEX_BACKGROUND = 257; @@ -87,4 +89,24 @@ public static int decodeEffect(long style) { return (int) (style & 0b11111111111); } + public static long encodeBitmap(int num, int X, int Y) { + return ((long)num << 16) | ((long)Y << 32) | ((long)X << 48) | BITMAP; + } + + public static boolean isBitmap(long style) { + return (style & 0x8000) != 0; + } + + public static int bitmapNum(long style) { + return (int)(style & 0xffff0000) >> 16; + } + + public static int bitmapX(long style) { + return (int)((style >> 48) & 0xfff); + } + + public static int bitmapY(long style) { + return (int)((style >> 32) & 0xfff); + } + } diff --git a/terminal-emulator/src/main/java/com/termux/terminal/WorkingTerminalBitmap.java b/terminal-emulator/src/main/java/com/termux/terminal/WorkingTerminalBitmap.java new file mode 100644 index 0000000000..e9f0e2ed05 --- /dev/null +++ b/terminal-emulator/src/main/java/com/termux/terminal/WorkingTerminalBitmap.java @@ -0,0 +1,110 @@ +package com.termux.terminal; + +import android.graphics.Bitmap; + +/** + * A circular buffer of {@link TerminalRow}:s which keeps notes about what is visible on a logical screen and the scroll + * history. + *

    + * See {@link #externalToInternalRow(int)} for how to map from logical screen rows to array indices. + */ +public final class WorkingTerminalBitmap { + final private int sixelInitialColorMap[] = {0xFF000000, 0xFF3333CC, 0xFFCC2323, 0xFF33CC33, 0xFFCC33CC, 0xFF33CCCC, 0xFFCCCC33, 0xFF777777, + 0xFF444444, 0xFF565699, 0xFF994444, 0xFF569956, 0xFF995699, 0xFF569999, 0xFF999956, 0xFFCCCCCC}; + private int[] colorMap; + private int curX; + private int curY; + private int color; + public int width; + public int height; + public Bitmap bitmap; + private static final String LOG_TAG = "WorkingTerminalBitmap"; + + public WorkingTerminalBitmap(int w, int h) { + try { + bitmap = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888); + } catch (OutOfMemoryError e) { + Logger.logWarn(null, LOG_TAG, "Out of memory - sixel ignored"); + bitmap = null; + } + bitmap.eraseColor(0); + width = 0; + height = 0; + curX = 0; + curY = 0; + colorMap = new int[256]; + for (int i=0; i<16; i++) { + colorMap[i] = sixelInitialColorMap[i]; + } + color = colorMap[0]; + } + + public void sixelChar(int c, int rep) { + if (bitmap == null) { + return; + } + if (c == '$') { + curX = 0; + return; + } + if (c == '-') { + curX = 0; + curY += 6; + return; + } + if (bitmap.getWidth() < curX + rep) { + try { + bitmap = TerminalBitmap.resizeBitmap(bitmap, curX + rep + 100, bitmap.getHeight()); + } catch(OutOfMemoryError e) { + Logger.logWarn(null, LOG_TAG, "Out of memory - sixel truncated"); + } + } + if (bitmap.getHeight() < curY + 6) { + // Very unlikely to resize both at the same time + try { + bitmap = TerminalBitmap.resizeBitmap(bitmap, bitmap.getWidth(), curY + 100); + } catch(OutOfMemoryError e) { + Logger.logWarn(null, LOG_TAG, "Out of memory - sixel truncated"); + } + } + if (curX + rep > bitmap.getWidth()) { + rep = bitmap.getWidth() - curX; + } + if ( curY + 6 > bitmap.getHeight()) { + return; + } + if (rep > 0 && c >= '?' && c <= '~') { + int b = c - '?'; + if (curY + 6 > height) { + height = curY + 6; + } + while (rep-- > 0) { + for (int i = 0 ; i < 6 ; i++) { + if ((b & (1< width) { + width = curX; + } + } + } + } + + public void sixelSetColor(int col) { + if (col >= 0 && col < 256) { + color = colorMap[col]; + } + } + + public void sixelSetColor(int col, int r, int g, int b) { + if (col >= 0 && col < 256) { + int red = Math.min(255, r*255/100); + int green = Math.min(255, g*255/100); + int blue = Math.min(255, b*255/100); + color = 0xff000000 + (red << 16) + (green << 8) + blue; + colorMap[col] = color; + } + } +} 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 214df331b3..2be86f17fa 100644 --- a/terminal-view/src/main/java/com/termux/view/TerminalRenderer.java +++ b/terminal-view/src/main/java/com/termux/view/TerminalRenderer.java @@ -1,8 +1,11 @@ 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; @@ -66,6 +69,7 @@ public final void render(TerminalEmulator mEmulator, Canvas canvas, int topRow, final TerminalBuffer screen = mEmulator.getScreen(); final int[] palette = mEmulator.mColors.mCurrentColors; final int cursorShape = mEmulator.getCursorStyle(); + mEmulator.setCellSize((int)mFontWidth, (int)mFontLineSpacing); if (reverseVideo) canvas.drawColor(palette[TextStyle.COLOR_INDEX_FOREGROUND], PorterDuff.Mode.SRC); @@ -99,10 +103,28 @@ 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.isBitmap(style)) { + Bitmap bm = mEmulator.getScreen().getSixelBitmap(codePoint, style); + if (bm != null) { + float left = column * mFontWidth; + float top = heightOffset - mFontLineSpacing; + RectF r = new RectF(left, top, left + mFontWidth, top + mFontLineSpacing); + canvas.drawBitmap(mEmulator.getScreen().getSixelBitmap(codePoint, style), mEmulator.getScreen().getSixelRect(codePoint, style), r, 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 @@ -113,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; From 69ce502d8b17d6103f2cd6276dfe21ba9d1d9778 Mon Sep 17 00:00:00 2001 From: agnostic-apollo Date: Thu, 9 Apr 2026 15:41:29 +0500 Subject: [PATCH 08/11] Added: Add terminal support for Bitmaps, Sixel (`DCS q`) and iTerm Image (`OSC 1337`) Support for displaying bitmaps inside the terminal has been added via `TerminalBitmap` which can be created from image `byte[]` or sixel bitmap. The bitmaps are sliced to character cell sized slices. The `TerminalBuffer` stores a map for bitmap number to the `TerminalBitmap` loaded in the terminal. The bitmap number and coordinates are encoded in the `long` `TerminalRow.mStyle` for the `TerminalRow` character of a column by `TerminalBitmap#buildOrThrow()` by getting encoded value from `TextStyle.encodeTerminalBitmap()`. 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()`, then draws it using `Canvas.drawBitmap()` instead of `Canvas.drawText()`. Sixel images can be created with Sixel Device Control String command sent via `DCS q s..s ST` or `DCS P1; P2; P3; q s..s ST`. - The `TerminalEmulator` interprets sixel sequences, and sends them to `TerminalBuffer` for constructing a `TerminalSixel`. Once the sixel command has been completely processed, a `TerminalBitmap` is created from the `TerminalSixel`. If an error occurred during processing (like OOM), then remaining sixel command is completely read, but is ignored and no sixel is drawn (done by setting `mTerminalSixel` to `null` so that `TerminalBuffer.sixelReadData()` ignores further commands). - Since a sixel sequence can be very long to render a full image and can have length greater than `TERMINAL_CONTROL_ARGS__MAX_LENGTH` (`16384`), the entire sequence is not stored in the `mTerminalControlArgs` buffer before being processed as it will result in an overflow error, instead as soon as length crosses `TERMINAL_CONTROL_ARGS__MAX_LENGTH / 2` and a complete sixel sub command (`#`, `!`, or `"`) has been received, it is immediately processed, and then further commands are read after emptying buffer. - If "rough" horizontal and vertical size of image is received at start of sixel data string with a `Raster Attributes` command, like done by `img2sixel` command, then sixel commands args buffer capacity (`mTerminalControlArgs`) is increased and sixel bitmap in `TerminalSixel` is resized 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. - The `4` (sixel) value has been added to `CSI` `Primary Device Attributes` terminal response. The `img2sixel` command can be used to display sixel images after installing with `libsixel` package with `pkg install libsixel`, like with `img2sixel --width=1000px image.jpg`. To manually send an escape sequence, check the `digiater.nl` link below, but it is too cumbersome to create images large enough to be easy viewable in the terminal. See Also: - 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 iTerm images can be created with `1337` Operating System Control command. - Both `File=` and `MultipartFile=` (chunk based) protocols are supported. The `inline` parameter should be `1` to display inline images in the terminal. Downloading images to Downloads folder with the value `0` will be ignored as that is not supported. - The escape sequences/image data cannot be greater than `TERMINAL_CONTROL_ARGS__MAX_LENGTH` (`16384`) bytes if sent via (`File=`) protocol, otherwise it will be ignored with an overflow error. For larger images, send images via `MultipartFile=` protocol in chunks with `FilePart=`, the `imgcat` utility uses that with 200-byte chunks if `--legacy` flag is not passed. - The `TerminalEmulator` interprets iTerm images sequences and creates an `ITermImage` to process parameters and store the base64 encoded image sent. Once all the data has been received, which can be over multiple `OSC` commands for `MultipartFile=` protocol, the encoded image is decoded to a `byte[]`, which is then passed to `TerminalBuffer`, which creates a `TerminalBitmap` for the image. The `imgcat` utility can be used for sending images, like with `imgcat --width 1000px image.jpg` (`MultipartFile=`) or `imgcat --width 1000px --legacy image.jpg` (`File=`). To manually send an escape sequence, run `echo -en '\e]1337;File=inline=1;keepAspectRatio=0;width=1000px;:' ; base64 -w 0 ./image.jpg ; echo -e '\e\\'` (`File=`). See Also: - https://iterm2.com/documentation-images.html - https://iterm2.com/utilities/imgcat - https://github.com/gnachman/iTerm2-shell-integration/blob/d1d4012068c3c6761d5676c28ed73e0e2df2b715/utilities/imgcat --- .../java/com/termux/terminal/ITermImage.java | 399 +++++++++ .../com/termux/terminal/TerminalBitmap.java | 458 +++++++---- .../com/termux/terminal/TerminalBuffer.java | 304 +++++-- .../com/termux/terminal/TerminalEmulator.java | 761 ++++++++++++------ .../java/com/termux/terminal/TerminalRow.java | 10 +- .../com/termux/terminal/TerminalSixel.java | 289 +++++++ .../java/com/termux/terminal/TextStyle.java | 30 +- .../terminal/WorkingTerminalBitmap.java | 110 --- .../com/termux/view/TerminalRenderer.java | 12 +- 9 files changed, 1775 insertions(+), 598 deletions(-) create mode 100644 terminal-emulator/src/main/java/com/termux/terminal/ITermImage.java create mode 100644 terminal-emulator/src/main/java/com/termux/terminal/TerminalSixel.java delete mode 100644 terminal-emulator/src/main/java/com/termux/terminal/WorkingTerminalBitmap.java 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..b8da113c6d --- /dev/null +++ b/terminal-emulator/src/main/java/com/termux/terminal/ITermImage.java @@ -0,0 +1,399 @@ +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 <= 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 index 9758705bdc..38eccad1f8 100644 --- a/terminal-emulator/src/main/java/com/termux/terminal/TerminalBitmap.java +++ b/terminal-emulator/src/main/java/com/termux/terminal/TerminalBitmap.java @@ -1,187 +1,353 @@ package com.termux.terminal; -import java.util.Arrays; -import java.util.HashSet; -import java.util.Set; - import android.graphics.Bitmap; import android.graphics.BitmapFactory; -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 - * history. - *

    - * See {@link #externalToInternalRow(int)} for how to map from logical screen rows to array indices. + * A terminal bitmap for images. */ public class TerminalBitmap { - public Bitmap bitmap; - public int cellWidth; - public int cellHeight; - public int scrollLines; - public int[] cursorDelta; - private static final String LOG_TAG = "TerminalBitmap"; - - - public TerminalBitmap(int num, WorkingTerminalBitmap sixel, int Y, int X, int cellW, int cellH, TerminalBuffer screen) { - Bitmap bm = sixel.bitmap; - bm = resizeBitmapConstraints(bm, sixel.width, sixel.height, cellW, cellH, screen.mColumns - X); - addBitmap(num, bm, Y, X, cellW, cellH, screen); + + 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; + } } - public TerminalBitmap(int num, byte[] image, int Y, int X, int cellW, int cellH, int width, int height, boolean aspect, TerminalBuffer screen) { - Bitmap bm = null; - int imageHeight; - int imageWidth; - int newWidth = width; - int newHeight = height; - if (height > 0 || width > 0) { - BitmapFactory.Options options = new BitmapFactory.Options(); - options.inJustDecodeBounds = true; - try { - BitmapFactory.decodeByteArray(image, 0, image.length, options); - } catch (Exception e) { - Logger.logWarn(null, LOG_TAG, "Cannot decode image"); + + /** 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 (image == null) { + Logger.logError(terminalBuffer.getClient(), LOG_TAG, + "Create terminal bitmap " + bitmapNum + " from image byte array failed:" + + " Image data not set"); + return null; } - imageHeight = options.outHeight; - imageWidth = options.outWidth; - if (aspect) { - double wFactor = 9999.0; - double hFactor = 9999.0; - if (width > 0) { - wFactor = (double)width / imageWidth; + + 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()); } - if (height > 0) { - hFactor = (double)height / imageHeight; + + + 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; + } } - double factor = Math.min(wFactor, hFactor); - newWidth = (int)(factor * imageWidth); - newHeight = (int)(factor * imageHeight); - } else { - if (height <= 0) { - newHeight = imageHeight; + + int scaleFactor = 1; + while (imageHeight >= 2 * newHeight * scaleFactor && imageWidth >= 2 * newWidth * scaleFactor) { + scaleFactor = scaleFactor * 2; } - if (width <= 0) { - newWidth = imageWidth; + + + // Create bitmap from image. + try { + if (scaleFactor > 1) { + // Subsample the original image to get a smaller image to save memory. + BitmapFactory.Options scaleOptions = new BitmapFactory.Options(); + scaleOptions.inSampleSize = scaleFactor; + newBitmap = BitmapFactory.decodeByteArray(image, 0, image.length, scaleOptions); + } else { + 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:" + + " Decode scaled bitmap for scale factor " + scaleFactor + " failed: " + t.getMessage()); + return null; } - } - int scaleFactor = 1; - while (imageHeight >= 2 * newHeight * scaleFactor && imageWidth >= 2 * newWidth * scaleFactor) { - scaleFactor = scaleFactor * 2; - } - BitmapFactory.Options scaleOptions = new BitmapFactory.Options(); - scaleOptions.inSampleSize = scaleFactor; - try { - bm = BitmapFactory.decodeByteArray(image, 0, image.length, scaleOptions); - } catch (Exception e) { - Logger.logWarn(null, LOG_TAG, "Out of memory, cannot decode image"); - bitmap = null; - return; - } - if (bm == null) { - Logger.logWarn(null, LOG_TAG, "Could not decode image"); - bitmap = null; - return; - } - int maxWidth = (screen.mColumns - X) * cellW; - if (newWidth > maxWidth) { - int cropWidth = bm.getWidth() * maxWidth / newWidth; + if (newBitmap == null) { + Logger.logError(terminalBuffer.getClient(), LOG_TAG, + "Create terminal bitmap " + bitmapNum + " from image byte array failed:" + + " Decoded scaled bitmap not set for scale factor " + scaleFactor); + 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 { - bm = Bitmap.createBitmap(bm, 0, 0, cropWidth, bm.getHeight()); - newWidth = maxWidth; - } catch(OutOfMemoryError e) { - // This is just a memory optimization. If it fails, - // continue (and probably fail later). + 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; } } - try { - bm = Bitmap.createScaledBitmap(bm, newWidth, newHeight, true); - } catch(OutOfMemoryError e) { - Logger.logWarn(null, LOG_TAG, "Out of memory, cannot rescale image"); - bm = 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; } - } else { - try { - bm = BitmapFactory.decodeByteArray(image, 0, image.length); - } catch (OutOfMemoryError e) { - Logger.logWarn(null, LOG_TAG, "Out of memory, cannot decode image"); + + + 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; } - } - if (bm == null) { - Logger.logWarn(null, LOG_TAG, "Cannot decode image"); - bitmap = null; - return; + 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; } + } - bm = resizeBitmapConstraints(bm, bm.getWidth(), bm.getHeight(), cellW, cellH, screen.mColumns - X); - addBitmap(num, bm, Y, X, cellW, cellH, screen); - cursorDelta = new int[] {scrollLines, (bitmap.getWidth() + cellW - 1) / cellW}; + + /** 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; + } } - private void addBitmap(int num, Bitmap bm, int Y, int X, int cellW, int cellH, TerminalBuffer screen) { - if (bm == null) { - bitmap = null; - return; + /** 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 width = bm.getWidth(); - int height = bm.getHeight(); - cellWidth = cellW; - cellHeight = cellH; - int w = Math.min(screen.mColumns - X, (width + cellW - 1) / cellW); - int h = (height + cellH - 1) / cellH; + + 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 cellW * Columns || (w % cellW) != 0 || (h % cellH) != 0) { - int newW = Math.min(cellW * Columns, ((w - 1) / cellW) * cellW + cellW); - int newH = ((h - 1) / cellH) * cellH + cellH; - try { - bm = resizeBitmap(bm, newW, newH); - } catch(OutOfMemoryError e) { - // Only a minor display glitch in this case - } + 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; } - return bm; } + } 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 8e8620d393..fea81faf41 100644 --- a/terminal-emulator/src/main/java/com/termux/terminal/TerminalBuffer.java +++ b/terminal-emulator/src/main/java/com/termux/terminal/TerminalBuffer.java @@ -6,7 +6,6 @@ import java.util.HashMap; import android.graphics.Bitmap; -import android.graphics.BitmapFactory; import android.graphics.Rect; import android.os.SystemClock; @@ -19,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; @@ -29,32 +34,74 @@ public final class TerminalBuffer { /** The index in the circular buffer where the visible screen starts. */ private int mScreenFirstRow = 0; - public HashMap bitmaps; - public WorkingTerminalBitmap workingBitmap; - private boolean hasBitmaps; - private long bitmapLastGC; + /** + * 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); - hasBitmaps = false; - bitmaps = new HashMap(); - bitmapLastGC = SystemClock.uptimeMillis(); + mTerminalBitmaps = new HashMap<>(); + mTerminalBitmapsLastGC = SystemClock.uptimeMillis(); + } + + + + public TerminalSessionClient getClient() { + return mClient; } + public String getTranscriptText() { return getSelectedText(0, -getActiveTranscriptRows(), mColumns, mScreenRows).trim(); } @@ -419,27 +466,9 @@ public void scrollDownOneLine(int topMargin, int bottomMargin, long style) { if (mLines[blankRow] == null) { mLines[blankRow] = new TerminalRow(mColumns, style); } else { - // find if a bitmap is completely scrolled out - Set used = new HashSet(); - if(mLines[blankRow].mHasBitmap) { - for (int column = 0; column < mColumns; column++) { - final long st = mLines[blankRow].getStyle(column); - if (TextStyle.isBitmap(st)) { - used.add((int)(st >> 16) & 0xffff); - } - } - TerminalRow nextLine = mLines[(blankRow + 1) % mTotalRows]; - if(nextLine.mHasBitmap) { - for (int column = 0; column < mColumns; column++) { - final long st = nextLine.getStyle(column); - if (TextStyle.isBitmap(st)) { - used.remove((int)(st >> 16) & 0xffff); - } - } - } - for(Integer bm: used) { - bitmaps.remove(bm); - } + // Remove bitmaps that are completely scrolled out. + if(mLines[blankRow].mHasTerminalBitmap) { + removeScrolledOutTerminalBitmaps(blankRow); } mLines[blankRow].clear(style); } @@ -528,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); @@ -536,92 +565,197 @@ public void clearTranscript() { Arrays.fill(mLines, mScreenFirstRow - mActiveTranscriptRows, mScreenFirstRow, null); } mActiveTranscriptRows = 0; - bitmaps.clear(); - hasBitmaps = false; + clearTerminalBitmaps(); } - public Bitmap getSixelBitmap(int codePoint, long style) { - return bitmaps.get(TextStyle.bitmapNum(style)).bitmap; + + + public synchronized TerminalBitmap getTerminalBitmap(long style) { + int bitmapNum = TextStyle.getTerminalBitmapNum(style); + return bitmapNum >= TERMINAL_BITMAP__NUM_START ? mTerminalBitmaps.get(bitmapNum): null; } - public Rect getSixelRect(int codePoint, long style ) { - TerminalBitmap bm = bitmaps.get(TextStyle.bitmapNum(style)); - int x = TextStyle.bitmapX(style); - int y = TextStyle.bitmapY(style); - Rect r = new Rect(x * bm.cellWidth, y * bm.cellHeight, (x+1) * bm.cellWidth, (y+1) * bm.cellHeight); - return r; + public synchronized void clearTerminalBitmaps() { + mTerminalBitmaps.clear(); } - public void sixelStart(int width, int height) { - workingBitmap = new WorkingTerminalBitmap(width, height); + public synchronized Bitmap getSixelBitmap(long style) { + TerminalBitmap terminalBitmap = getTerminalBitmap(style); + return terminalBitmap != null ? terminalBitmap.mBitmap : null; } - public void sixelChar(int c, int rep) { - workingBitmap.sixelChar(c, rep); + + 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 void sixelSetColor(int col) { - workingBitmap.sixelSetColor(col); + + public synchronized void sixelStart(int width, int height) { + mTerminalSixel = TerminalSixel.build(getClient(), width, height); } - public void sixelSetColor(int col, int r, int g, int b) { - workingBitmap.sixelSetColor(col, r, g, b); + 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; } - private int findFreeBitmap() { - int i = 0; - while (bitmaps.containsKey(i)) { - i++; + /** Clears the {@link #mTerminalSixel} by setting it to `null`. */ + 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 i; + return true; } - public int sixelEnd(int Y, int X, int cellW, int cellH) { - int num = findFreeBitmap(); - bitmaps.put(num, new TerminalBitmap(num, workingBitmap, Y, X, cellW, cellH, this)); - workingBitmap = null; - if (bitmaps.get(num).bitmap == null) { - bitmaps.remove(num); - return 0; + 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; + } } - hasBitmaps = true; - bitmapGC(30000); - return bitmaps.get(num).scrollLines; + return true; + } + + public synchronized void sixelSetColor(int color) { + if (mTerminalSixel != null) + mTerminalSixel.setColor(color); + } + + public synchronized void sixelSetRGBColor(int color, int r, int g, int b) { + if (mTerminalSixel != null) + mTerminalSixel.setRGBColor(color, r, g, b); } - public int[] addImage(byte[] image, int Y, int X, int cellW, int cellH, int width, int height, boolean aspect) { - int num = findFreeBitmap(); - bitmaps.put(num, new TerminalBitmap(num, image, Y, X, cellW, cellH, width, height, aspect, this)); - if (bitmaps.get(num).bitmap == null) { - bitmaps.remove(num); - return new int[] {0,0}; + + + private synchronized int getFreeTerminalBitmapNum() { + int bitmapNum = TERMINAL_BITMAP__NUM_START; + while (mTerminalBitmaps.containsKey(bitmapNum)) { + bitmapNum++; + if (bitmapNum == TERMINAL_BITMAP__NUM_END) { + return -1; + } } - hasBitmaps = true; - bitmapGC(30000); - return bitmaps.get(num).cursorDelta; + return bitmapNum; } - public void bitmapGC(int timeDelta) { - if (!hasBitmaps || bitmapLastGC + timeDelta > SystemClock.uptimeMillis()) { + + 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 used = new HashSet(); + + Set bitmapsToKeep = new HashSet<>(); + for (int line = 0; line < mLines.length; line++) { - if(mLines[line] != null && mLines[line].mHasBitmap) { + if(mLines[line] != null && mLines[line].mHasTerminalBitmap) { for (int column = 0; column < mColumns; column++) { - final long st = mLines[line].getStyle(column); - if (TextStyle.isBitmap(st)) { - used.add((int)(st >> 16) & 0xffff); + long style = mLines[line].getStyle(column); + int bitmapNum = TextStyle.getTerminalBitmapNum(style); + if (bitmapNum >= TERMINAL_BITMAP__NUM_START) { + bitmapsToKeep.add(bitmapNum); } } } } - Set keys = new HashSet(bitmaps.keySet()); - for (Integer bn: keys) { - if (!used.contains(bn)) { - bitmaps.remove(bn); + + Set bitmapNums = new HashSet<>(mTerminalBitmaps.keySet()); + for (Integer bitmapNum: bitmapNums) { + if (!bitmapsToKeep.contains(bitmapNum)) { + mTerminalBitmaps.remove(bitmapNum); } } - bitmapLastGC = SystemClock.uptimeMillis(); + + 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 1fe43023bc..64473e019f 100644 --- a/terminal-emulator/src/main/java/com/termux/terminal/TerminalEmulator.java +++ b/terminal-emulator/src/main/java/com/termux/terminal/TerminalEmulator.java @@ -3,7 +3,6 @@ import android.util.Base64; import java.nio.charset.StandardCharsets; -import java.util.ArrayList; import java.util.Arrays; import java.util.Locale; import java.util.Objects; @@ -321,6 +320,33 @@ public final class TerminalEmulator { 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. @@ -331,12 +357,6 @@ public final class TerminalEmulator { /** The current state of the escape sequence state machine. One of the ESC_* constants. */ private int mEscapeState; - private boolean ESC_DCS_escape = false; - private boolean ESC_DCS_sixel = false; - private ArrayList ESC_OSC_data; - private int ESC_OSC_colon = 0; - private boolean ESC_OSC_outofmem = false; - private final SavedScreenState mSavedStateMain = new SavedScreenState(); private final SavedScreenState mSavedStateAlt = new SavedScreenState(); @@ -413,13 +433,6 @@ public final class TerminalEmulator { private static final String LOG_TAG = "TerminalEmulator"; - private int cellW = 12, cellH = 12; - - public void setCellSize(int w, int h) { - cellW = w; - cellH = h; - } - private boolean isDecsetInternalBitSet(int bit) { return (mCurrentDecSetFlags & bit) != 0; } @@ -474,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; @@ -491,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; } @@ -717,6 +748,8 @@ private void processByte(byte byteToProcess) { } public void processCodePoint(int b) { + mScreen.doTerminalBitmapsGC(300000); + if (mEscapeState == ESC_OSC && mIsFastPathOsc) { mContinueSequence = false; receiveOsc(b); @@ -748,7 +781,6 @@ public void processCodePoint(int b) { return; } - mScreen.bitmapGC(300000); switch (b) { case 0: // Null character (NUL, ^@). Do nothing. break; @@ -783,14 +815,17 @@ public void processCodePoint(int b) { case 10: // Line feed (LF, \n). case 11: // Vertical tab (VT, \v). case 12: // Form feed (FF, \f). - if((mEscapeState != ESC_DCS || !ESC_DCS_sixel) && ESC_OSC_colon <= 0) { - // Ignore CR/LF inside sixels or iterm2 data + // 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). - if((mEscapeState != ESC_DCS || !ESC_DCS_sixel) && ESC_OSC_colon <= 0) { - // Ignore CR/LF inside sixels or iterm2 data + if (! + (mEscapeState == ESC_DCS || + ((mEscapeState == ESC_OSC || mEscapeState == ESC_OSC__ESC) && mIgnoreCrLfForOsc))) { setCursorCol(mLeftMargin); } break; @@ -1099,24 +1134,34 @@ public void processCodePoint(int b) { * Do {@link #ESC_DCS}. Check its docs for more info. */ private void doDcs(final int b) { - boolean firstSixel = false; - if (!ESC_DCS_sixel && (b=='$' || b=='-' || b=='#')) { - //Check if sixel sequence that needs breaking - String dcs = mTerminalControlArgs.toString(); - if (dcs.matches("[0-9;]*q.*")) { - firstSixel = true; - } - } - if ( // End of DCS if string terminator ST `ESC \` received. - (ESC_DCS__ESC && b == '\\') - // Sixel sequences may be very long. '$' and '!' are natural for breaking the sequence. - || firstSixel || (ESC_DCS_sixel && (b=='$' || b=='-' || b=='#')) + (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(); - // DCS $ q P t ST. Request Status String (DECRQSS) + // 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: @@ -1214,93 +1259,46 @@ private void doDcs(final int b) { Logger.logError(mClient, LOG_TAG, "Invalid device termcap/terminfo name of odd length: " + part); } } - } else if (ESC_DCS_sixel || dcs.matches("[0-9;]*q.*")) { - int pos = 0; - if (!ESC_DCS_sixel) { - ESC_DCS_sixel = true; - mScreen.sixelStart(100, 100); - while (dcs.codePointAt(pos) != 'q') { - pos++; - } - pos++; - } - if (b=='$' || b=='-') { - // Add to string - dcs = dcs + (char)b; - } - int rep = 1; - while (pos < dcs.length()) { - if (dcs.codePointAt(pos) == '"') { - pos++; - int args[]={0,0,0,0}; - int arg = 0; - while (pos < dcs.length() && ((dcs.codePointAt(pos) >= '0' && dcs.codePointAt(pos) <= '9') || dcs.codePointAt(pos) == ';')) { - if (dcs.codePointAt(pos) >= '0' && dcs.codePointAt(pos) <= '9') { - args[arg] = args[arg] * 10 + dcs.codePointAt(pos) - '0'; - } else { - arg++; - if (arg > 3) { - break; - } - } - pos++; - } - if (pos == dcs.length()) { - break; - } - } else if (dcs.codePointAt(pos) == '#') { - int col = 0; - pos++; - while (pos < dcs.length() && dcs.codePointAt(pos) >= '0' && dcs.codePointAt(pos) <= '9') { - col = col * 10 + dcs.codePointAt(pos++) - '0'; - } - if (pos == dcs.length() || dcs.codePointAt(pos) != ';') { - mScreen.sixelSetColor(col); - } else { - pos++; - int args[]={0,0,0,0}; - int arg = 0; - while (pos < dcs.length() && ((dcs.codePointAt(pos) >= '0' && dcs.codePointAt(pos) <= '9') || dcs.codePointAt(pos) == ';')) { - if (dcs.codePointAt(pos) >= '0' && dcs.codePointAt(pos) <= '9') { - args[arg] = args[arg] * 10 + dcs.codePointAt(pos) - '0'; - } else { - arg++; - if (arg > 3) { - break; - } - } - pos++; - } - if (args[0] == 2) { - mScreen.sixelSetColor(col, args[1], args[2], args[3]); - } - } - } else if (dcs.codePointAt(pos) == '!') { - rep = 0; - pos++; - while (pos < dcs.length() && dcs.codePointAt(pos) >= '0' && dcs.codePointAt(pos) <= '9') { - rep = rep * 10 + dcs.codePointAt(pos++) - '0'; - } - } else if (dcs.codePointAt(pos) == '$' || dcs.codePointAt(pos) == '-' || (dcs.codePointAt(pos) >= '?' && dcs.codePointAt(pos) <= '~')) { - mScreen.sixelChar(dcs.codePointAt(pos++), rep); - rep = 1; - } else { - pos++; - } + } + // 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 (b == '\\') { - ESC_DCS_sixel = false; - int n = mScreen.sixelEnd(mCursorRow, mCursorCol, cellW, cellH); - for(;n>0;n--) { + + 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 { - mTerminalControlArgs.setLength(0); - if (b=='#') { - mTerminalControlArgs.appendCodePoint(b); + 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); } - // Do not finish sequence - continueSequence(mEscapeState); + + // 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 { @@ -1316,14 +1314,310 @@ private void doDcs(final int b) { 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`. + if (b == 'q') { + startIfSixelDcs(); + } else if (b == ';' || (b >= '0' && b <= '9')) { + // Ignore. + } else { + ESC_DCS__CHECK_IF_SIXEL = false; + } + } } } 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(); + } + } + + 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.sixelSetRGBColor(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); @@ -1749,7 +2043,6 @@ private void doEsc(int b) { clearTerminalControlArgs(); clearOscTypeVariables(); continueSequence(ESC_OSC); - ESC_OSC_colon = -1; break; case '>': // DECKPNM setDecsetinternalBit(DECSET_BIT_APPLICATION_KEYPAD, false); @@ -2312,29 +2605,6 @@ private void receiveOsc(final int b) { if (mOscType == -1) { setOscTypeVariables(); } - - if (ESC_OSC_colon == -1 && b == ':') { - // Collect base64 data for OSC 1337 - ESC_OSC_colon = mTerminalControlArgs.length(); - ESC_OSC_data = new ArrayList(65536); - ESC_OSC_outofmem = false; - } else if (ESC_OSC_colon >= 0 && mTerminalControlArgs.length() - ESC_OSC_colon == 4) { - if (!ESC_OSC_outofmem) { - try { - byte[] decoded = Base64.decode(mTerminalControlArgs.substring(ESC_OSC_colon), 0); - for (int i = 0 ; i < decoded.length; i++) { - ESC_OSC_data.add(decoded[i]); - } - } catch(Exception e) { - // Ignore non-Base64 data. - } catch(OutOfMemoryError e) { - // Out of memory - // Keep decoding, but fo not collect the data - ESC_OSC_outofmem = true; - } - } - mTerminalControlArgs.setLength(ESC_OSC_colon); - } break; } } @@ -2385,6 +2655,16 @@ void setOscTypeVariables() { 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) { @@ -2411,8 +2691,6 @@ public void clearOscTypeVariables() { */ private void doOsc(String bellOrStringTerminator) { int value = -1; - int osc_colon = ESC_OSC_colon; - ESC_OSC_colon = -1; String textParameter = ""; int argsLength = mTerminalControlArgs.length(); @@ -2420,7 +2698,10 @@ private void doOsc(String bellOrStringTerminator) { for (int i = 0; i < argsLength; i++) { char b = mTerminalControlArgs.charAt(i); if (b == ';') { - textParameter = mTerminalControlArgs.substring(i + 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'); @@ -2550,109 +2831,126 @@ private void doOsc(String bellOrStringTerminator) { break; case 119: // Reset highlight color. break; - case 1337: // iTerm extemsions - if (textParameter.startsWith("File=")) { - int pos = 5; - boolean inline = false; - boolean aspect = true; - int width = -1; - int height = -1; - while (pos < textParameter.length()) { - int eqpos = textParameter.indexOf('=', pos); - if (eqpos == -1) { - 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. } - int semicolonpos = textParameter.indexOf(';', eqpos); - if (semicolonpos == -1) { - semicolonpos = textParameter.length() - 1; - } - String k = textParameter.substring(pos, eqpos); - String v = textParameter.substring(eqpos + 1, semicolonpos); - pos = semicolonpos + 1; - if (k.equalsIgnoreCase("inline")) { - inline = v.equals("1"); - } - if (k.equalsIgnoreCase("preserveAspectRatio")) { - aspect = ! v.equals("0"); - } - if (k.equalsIgnoreCase("width")) { - double factor = cellW; - int div = 1; - int e = v.length(); - if (v.endsWith("px")) { - factor = 1; - e -= 2; - } else if (v.endsWith("%")) { - factor = 0.01 * cellW * mColumns; - e -= 1; - } - try { - width = (int)(factor * Integer.parseInt(v.substring(0,e))); - } catch(Exception ex) { - } - } - if (k.equalsIgnoreCase("height")) { - double factor = cellH; - int div = 1; - int e = v.length(); - if (v.endsWith("px")) { - factor = 1; - e -= 2; - } else if (v.endsWith("%")) { - factor = 0.01 * cellH * mRows; - e -= 1; - } - try { - height = (int)(factor * Integer.parseInt(v.substring(0,e))); - } catch(Exception ex) { + + 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; } } } - if (!inline) { - finishSequence(); - return; + // `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; + } } - if (osc_colon >= 0 && mTerminalControlArgs.length() > osc_colon) { - while (mTerminalControlArgs.length() - osc_colon < 4) { - mTerminalControlArgs.append('='); + // `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; } - try { - byte[] decoded = Base64.decode(mTerminalControlArgs.substring(osc_colon), 0); - for (int i = 0 ; i < decoded.length; i++) { - ESC_OSC_data.add(decoded[i]); - } - } catch(Exception e) { - // Ignore non-Base64 data. + + if (!mITermImage.readImage(mTerminalControlArgs, /* `1337;FilePart=` */ 14)) { + mITermImage = null; } - mTerminalControlArgs.setLength(osc_colon); } - if (osc_colon >= 0) { - byte[] result = new byte[ESC_OSC_data.size()]; - for(int i = 0; i < ESC_OSC_data.size(); i++) { - result[i] = ESC_OSC_data.get(i).byteValue(); + // `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; } - int[] res = mScreen.addImage(result, mCursorRow, mCursorCol, cellW, cellH, width, height, aspect); - int col = res[1] + mCursorCol; - if (col < mColumns -1) { - res[0] -= 1; - } else { - col = 0; + + iTermImage = mITermImage; + mITermImage = null; // Free global reference so that memory is freed at end function. + if ( + !iTermImage.setMultiPartImageRead() || + !iTermImage.decodeImage()) { + iTermImage = null; } - for(;res[0] > 0; res[0]--) { - doLinefeed(); + } + + // 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; } - mCursorCol = col; - ESC_OSC_data.clear(); - } else { + // Saving files in downloads folder is not supported currently. + else {} } - } else if (textParameter.startsWith("ReportCellSize")) { - mSession.write(String.format(Locale.US, "\0331337;ReportCellSize=%d;%d\007", cellH, cellW)); + break; + } else if (controlCommandPrefix.startsWith("ReportCellSize")) { + mSession.write(String.format(Locale.ENGLISH, "\0331337;ReportCellSize=%d;%d\007", mCellHeightPixels, mCellWidthPixels)); } - break; + + // 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(); } @@ -3129,12 +3427,9 @@ public void reset() { mColors.reset(); mSession.onColorsChanged(); - ESC_DCS_escape = false; - ESC_DCS_sixel = false; - ESC_OSC_colon = -1; - 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 a70103482f..14de528a07 100644 --- a/terminal-emulator/src/main/java/com/termux/terminal/TerminalRow.java +++ b/terminal-emulator/src/main/java/com/termux/terminal/TerminalRow.java @@ -49,8 +49,8 @@ public final class TerminalRow { final long[] mStyle; /** If this row might contain chars with width != 1, used for deactivating fast path */ boolean mHasNonOneWidthOrSurrogateChars; - /** If this row has a bitmap. Used for performace only */ - public boolean mHasBitmap; + /** 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) { @@ -148,7 +148,7 @@ public void clear(long style) { Arrays.fill(mStyle, style); mSpaceUsed = mColumns; mHasNonOneWidthOrSurrogateChars = false; - mHasBitmap = false; + mHasTerminalBitmap = false; } // https://github.com/steven676/Android-Terminal-Emulator/commit/9a47042620bec87617f0b4f5d50568535668fe26 @@ -158,8 +158,8 @@ public void setChar(int columnToSet, int codePoint, long style) { mStyle[columnToSet] = style; - if (!mHasBitmap && TextStyle.isBitmap(style)) { - mHasBitmap = true; + if (!mHasTerminalBitmap && TextStyle.isTerminalBitmap(style)) { + mHasTerminalBitmap = true; } final int newCodePointDisplayWidth = WcWidth.width(codePoint); 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..d7f438a1fb --- /dev/null +++ b/terminal-emulator/src/main/java/com/termux/terminal/TerminalSixel.java @@ -0,0 +1,289 @@ +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 7ee4b06ebc..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,8 +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 bitmap slice, not text. */ - public final static int BITMAP = 1 << 15; + /** 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; @@ -89,24 +89,28 @@ public static int decodeEffect(long style) { return (int) (style & 0b11111111111); } - public static long encodeBitmap(int num, int X, int Y) { - return ((long)num << 16) | ((long)Y << 32) | ((long)X << 48) | BITMAP; - } - public static boolean isBitmap(long style) { - return (style & 0x8000) != 0; + + public static long encodeTerminalBitmap(int num, int x, int y) { + return ((long) x << 48) | ((long) y << 32) | ((long) num << 16) | TERMINAL_BITMAP; } - public static int bitmapNum(long style) { - return (int)(style & 0xffff0000) >> 16; + 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 bitmapX(long style) { - return (int)((style >> 48) & 0xfff); + public static int getTerminalBitmapX(long style) { + return (style & TERMINAL_BITMAP) != 0 ? (int) ((style >> 48) & 0xfff) : -1; } - public static int bitmapY(long style) { - return (int)((style >> 32) & 0xfff); + public static int getTerminalBitmapY(long style) { + return (style & TERMINAL_BITMAP) != 0 ? (int) ((style >> 32) & 0xfff) : -1; } } diff --git a/terminal-emulator/src/main/java/com/termux/terminal/WorkingTerminalBitmap.java b/terminal-emulator/src/main/java/com/termux/terminal/WorkingTerminalBitmap.java deleted file mode 100644 index e9f0e2ed05..0000000000 --- a/terminal-emulator/src/main/java/com/termux/terminal/WorkingTerminalBitmap.java +++ /dev/null @@ -1,110 +0,0 @@ -package com.termux.terminal; - -import android.graphics.Bitmap; - -/** - * A circular buffer of {@link TerminalRow}:s which keeps notes about what is visible on a logical screen and the scroll - * history. - *

    - * See {@link #externalToInternalRow(int)} for how to map from logical screen rows to array indices. - */ -public final class WorkingTerminalBitmap { - final private int sixelInitialColorMap[] = {0xFF000000, 0xFF3333CC, 0xFFCC2323, 0xFF33CC33, 0xFFCC33CC, 0xFF33CCCC, 0xFFCCCC33, 0xFF777777, - 0xFF444444, 0xFF565699, 0xFF994444, 0xFF569956, 0xFF995699, 0xFF569999, 0xFF999956, 0xFFCCCCCC}; - private int[] colorMap; - private int curX; - private int curY; - private int color; - public int width; - public int height; - public Bitmap bitmap; - private static final String LOG_TAG = "WorkingTerminalBitmap"; - - public WorkingTerminalBitmap(int w, int h) { - try { - bitmap = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888); - } catch (OutOfMemoryError e) { - Logger.logWarn(null, LOG_TAG, "Out of memory - sixel ignored"); - bitmap = null; - } - bitmap.eraseColor(0); - width = 0; - height = 0; - curX = 0; - curY = 0; - colorMap = new int[256]; - for (int i=0; i<16; i++) { - colorMap[i] = sixelInitialColorMap[i]; - } - color = colorMap[0]; - } - - public void sixelChar(int c, int rep) { - if (bitmap == null) { - return; - } - if (c == '$') { - curX = 0; - return; - } - if (c == '-') { - curX = 0; - curY += 6; - return; - } - if (bitmap.getWidth() < curX + rep) { - try { - bitmap = TerminalBitmap.resizeBitmap(bitmap, curX + rep + 100, bitmap.getHeight()); - } catch(OutOfMemoryError e) { - Logger.logWarn(null, LOG_TAG, "Out of memory - sixel truncated"); - } - } - if (bitmap.getHeight() < curY + 6) { - // Very unlikely to resize both at the same time - try { - bitmap = TerminalBitmap.resizeBitmap(bitmap, bitmap.getWidth(), curY + 100); - } catch(OutOfMemoryError e) { - Logger.logWarn(null, LOG_TAG, "Out of memory - sixel truncated"); - } - } - if (curX + rep > bitmap.getWidth()) { - rep = bitmap.getWidth() - curX; - } - if ( curY + 6 > bitmap.getHeight()) { - return; - } - if (rep > 0 && c >= '?' && c <= '~') { - int b = c - '?'; - if (curY + 6 > height) { - height = curY + 6; - } - while (rep-- > 0) { - for (int i = 0 ; i < 6 ; i++) { - if ((b & (1< width) { - width = curX; - } - } - } - } - - public void sixelSetColor(int col) { - if (col >= 0 && col < 256) { - color = colorMap[col]; - } - } - - public void sixelSetColor(int col, int r, int g, int b) { - if (col >= 0 && col < 256) { - int red = Math.min(255, r*255/100); - int green = Math.min(255, g*255/100); - int blue = Math.min(255, b*255/100); - color = 0xff000000 + (red << 16) + (green << 8) + blue; - colorMap[col] = color; - } - } -} 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 2be86f17fa..787902ce08 100644 --- a/terminal-view/src/main/java/com/termux/view/TerminalRenderer.java +++ b/terminal-view/src/main/java/com/termux/view/TerminalRenderer.java @@ -69,7 +69,6 @@ public final void render(TerminalEmulator mEmulator, Canvas canvas, int topRow, final TerminalBuffer screen = mEmulator.getScreen(); final int[] palette = mEmulator.mColors.mCurrentColors; final int cursorShape = mEmulator.getCursorStyle(); - mEmulator.setCellSize((int)mFontWidth, (int)mFontLineSpacing); if (reverseVideo) canvas.drawColor(palette[TextStyle.COLOR_INDEX_FOREGROUND], PorterDuff.Mode.SRC); @@ -104,13 +103,14 @@ public final void render(TerminalEmulator mEmulator, Canvas canvas, int topRow, 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.isBitmap(style)) { - Bitmap bm = mEmulator.getScreen().getSixelBitmap(codePoint, style); - if (bm != null) { + if (TextStyle.isTerminalBitmap(style)) { + Bitmap bitmap = mEmulator.getScreen().getSixelBitmap(style); + if (bitmap != null) { float left = column * mFontWidth; float top = heightOffset - mFontLineSpacing; - RectF r = new RectF(left, top, left + mFontWidth, top + mFontLineSpacing); - canvas.drawBitmap(mEmulator.getScreen().getSixelBitmap(codePoint, style), mEmulator.getScreen().getSixelRect(codePoint, style), r, null); + 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; From 3ec521f6046688c280a2a3c50763b5b19823a78e Mon Sep 17 00:00:00 2001 From: agnostic-apollo Date: Tue, 14 Apr 2026 01:17:12 +0500 Subject: [PATCH 09/11] Changed: Increase Sixel and iTerm Image limits The `TerminalBitmap.MAX_BITMAP_SIZE` defines the max size of a Terminal bitmap for its pixels. Each pixel is stored on 4 bytes for a `Bitmap.Config.ARGB_8888` bitmap color config. The value should normally be between `100-200MB` depending on device and Android version. Check the variable docs for more info. The sixel image size cannot be greater than `TerminalBitmap.MAX_BITMAP_SIZE`. The repeat value for sixel Graphics Repeat Introducer command cannot be greater than `TerminalSixel.SIXEL__MAX_REPEAT` (`8192`). The iTerm image data sent for `File=` and `MultipartFile=` protocols cannot be greater than `TerminalBitmap.MAX_BITMAP_SIZE` bytes. --- .../com/termux/terminal/AndroidUtils.java | 65 +++++++++ .../com/termux/terminal/TerminalBitmap.java | 124 +++++++++++++++++- .../com/termux/terminal/TerminalBuffer.java | 16 ++- .../com/termux/terminal/TerminalEmulator.java | 124 +++++++++++++----- .../com/termux/terminal/TerminalSixel.java | 52 +++----- 5 files changed, 310 insertions(+), 71 deletions(-) create mode 100644 terminal-emulator/src/main/java/com/termux/terminal/AndroidUtils.java diff --git a/terminal-emulator/src/main/java/com/termux/terminal/AndroidUtils.java b/terminal-emulator/src/main/java/com/termux/terminal/AndroidUtils.java new file mode 100644 index 0000000000..2f762c7bbc --- /dev/null +++ b/terminal-emulator/src/main/java/com/termux/terminal/AndroidUtils.java @@ -0,0 +1,65 @@ +package com.termux.terminal; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.util.Properties; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public class AndroidUtils { + + /** + * Get system properties as {@link Properties} from `/system/bin/getprop` command output + * that reads the `/system/build.prop` file. + * + * Sourced from https://github.com/termux/termux-app/blob/30ebb2dee381d292ade0f2868cfde0f9f20b89fe/termux-shared/src/main/java/com/termux/shared/android/AndroidUtils.java#L170. + */ + public static Properties getSystemProperties(String logTag) { + Properties systemProperties = new Properties(); + + // getprop commands returns values in the format `[key]: [value]` + // Regex matches string starting with a literal `[`, + // followed by one or more characters that do not match a closing square bracket as the key, + // followed by a literal `]: [`, + // followed by one or more characters as the value, + // followed by string ending with literal `]` + // multiline values will be ignored + Pattern propertiesPattern = Pattern.compile("^\\[([^]]+)]: \\[(.+)]$"); + + try { + Process process = new ProcessBuilder() + .command("/system/bin/getprop") + .redirectErrorStream(true) + .start(); + + InputStream inputStream = process.getInputStream(); + BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(inputStream)); + String line, key, value; + + while ((line = bufferedReader.readLine()) != null) { + Matcher matcher = propertiesPattern.matcher(line); + if (matcher.matches()) { + key = matcher.group(1); + value = matcher.group(2); + if (key != null && value != null && !key.isEmpty() && !value.isEmpty()) + systemProperties.put(key, value); + } + } + + bufferedReader.close(); + process.destroy(); + + } catch (IOException e) { + Logger.logStackTraceWithMessage(null, logTag, "Failed to get run \"/system/bin/getprop\" to get system properties.", e); + } + + //for (String key : systemProperties.stringPropertyNames()) { + // Logger.logVerbose(null, logTag, key + ": " + systemProperties.get(key)); + //} + + return systemProperties; + } + +} diff --git a/terminal-emulator/src/main/java/com/termux/terminal/TerminalBitmap.java b/terminal-emulator/src/main/java/com/termux/terminal/TerminalBitmap.java index 38eccad1f8..188f06bc2a 100644 --- a/terminal-emulator/src/main/java/com/termux/terminal/TerminalBitmap.java +++ b/terminal-emulator/src/main/java/com/termux/terminal/TerminalBitmap.java @@ -1,7 +1,16 @@ package com.termux.terminal; +import android.app.WallpaperManager; import android.graphics.Bitmap; import android.graphics.BitmapFactory; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.RecordingCanvas; +import android.graphics.Rect; +import android.graphics.RectF; +import android.os.Build; + +import java.util.Properties; /** * A terminal bitmap for images. @@ -10,6 +19,112 @@ public class TerminalBitmap { public static final String LOG_TAG = "TerminalBitmap"; + private static int initMaxBitmapSize() { + // Synced with `RecordingCanvas.MAX_BITMAP_SIZE`. + // - https://cs.android.com/android/platform/superproject/+/android-16.0.0_r1:frameworks/base/graphics/java/android/graphics/RecordingCanvas.java;l=42-50 + int defaultSize = + Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM ? + 150 * 1024 * 1024 : // 150 MB + 100 * 1024 * 1024; // 100 MB + + Properties systemProperties = AndroidUtils.getSystemProperties(LOG_TAG); + String maxTextureSizeString = systemProperties.getProperty("ro.hwui.max_texture_allocation_size"); + + if (maxTextureSizeString == null) return defaultSize; + + try { + int maxTextureSize = Integer.parseInt(maxTextureSizeString); + return maxTextureSize > 0 ? maxTextureSize : defaultSize; + } + catch (Exception e) { + return defaultSize; + } + } + + /** + * The max size of a Terminal {@link Bitmap} for its pixels. The limit is defined as per how + * `RecordingCanvas.MAX_BITMAP_SIZE` value is defined, check below for details. The value should + * normally be between `100-200MB` depending on device and Android version. + * + * Each pixel is stored on 4 bytes for a {@link Bitmap.Config#ARGB_8888} bitmap color config. + * The bitmap will have following memory usage for its respective resolution (`width x height x 4`). + * - 1280x720 (HD): 3,686,400 bytes/3.6MB. + * - 1920x1080 (FHD): 8,294,400 bytes/8MB. + * - 2560x1440 (QHD): 14,745,600 bytes/14.7MB. + * - 3840x2160 (4K UHD): 33,177,600 bytes/33MB. + * - 7680x4320 (8K UHD): 132,710,400 bytes/132MB. + * . + * - https://en.wikipedia.org/wiki/Display_resolution_standards#High-definition + * + * The terminal uses {@link Canvas#drawBitmap(Bitmap, Rect, RectF, Paint)} to draw the bitmap + * when `TerminalRenderer.render()` is called. + * + * The {@link Canvas} class defines `Canvas.MAXIMUM_BITMAP_SIZE` for the maximum dimension + * for a bitmap which is returned by {@link Canvas#getMaximumBitmapWidth()} and + * {@link Canvas#getMaximumBitmapHeight()}. It is hardcoded with the value `32766` as defined by + * Skia (2D graphics library), which technically has the limit `32767` as it requires supporting + * math on 16-bit buffers. + * - https://cs.android.com/android/_/android/platform/frameworks/base/+/f61970fc79e9c5cf340fa942597628242361864a + * - https://cs.android.com/android/platform/superproject/+/android-16.0.0_r1:frameworks/base/graphics/java/android/graphics/Canvas.java;l=76-78 + * - https://cs.android.com/android/platform/superproject/+/android-16.0.0_r1:external/skia/src/shaders/SkImageShader.cpp;l=254-267 + * + * The {@link RecordingCanvas} class defines `RecordingCanvas.MAX_BITMAP_SIZE` for the + * maximum size (not dimension) for a bitmap, which is checked by + * `RecordingCanvas.throwIfCannotDraw()` when `BaseRecordingCanvas.drawBitmap()` is called. + * The `RecordingCanvas` is a specialized implementation of the `Canvas` class that is designed + * to record draw commands for deferred rendering instead of executing draw commands instantly. + * By recording draw commands, they can be cached so that complex views can be efficiently + * re-drawn without recalculating them again for every frame. The caching part is similar to + * how a terminal behaves, where it stores all the bitmaps for rendering depending on scroll + * position. So both `RecordingCanvas` and a terminal require similar limits on bitmap + * sizes considering memory consumption limits of apps, and multiple bitmaps being loaded + * instead of a single one like for wallpapers, hence why `TerminalBitmap.MAX_BITMAP_SIZE` is + * synced with `RecordingCanvas`. + * The `RecordingCanvas.MAX_BITMAP_SIZE` is set from `ro.hwui.max_texture_allocation_size` + * system property if set for Android `>= 12`, otherwise `150MB` (`100MB` for Android `10-14`). + * The values `>= 150MB` are enough to support `7680x4320` (8K UHD) bitmaps. + * Some devices like larger xiaomi devices have `ro.hwui.max_texture_allocation_size` set to `209715200` (`200MB`). + * - https://cs.android.com/android/_/android/platform/frameworks/base/+/e4d011201cea40d46cb2b2eef401db8fddc5c9c6 + * - https://cs.android.com/android/_/android/platform/frameworks/base/+/0e717a9d06ded980908649393bd73e46ffafcd54 + * - https://cs.android.com/android/_/android/platform/frameworks/base/+/97396260ed06cc9d1834d4d8e4e649a3ef09f1f3 + * - https://cs.android.com/android/platform/superproject/+/android-16.0.0_r1:frameworks/base/graphics/java/android/graphics/RecordingCanvas.java;l=42-50 + * + * The Android wallpaper manager service also checks if dimensions of cropped wallpaper exceeds + * max texture size that the GPU can support, otherwise it will cause System UI to keep crashing + * because it can not initialize EGL with an appropriate surface. The `GLHelper.getMaxTextureSize()` + * returns the max texture size, which is defined by `sys.max_texture_size` system property if set, + * otherwise by value for `GL_MAX_TEXTURE_SIZE`. The `sys.max_texture_size` defines the maximum + * width or height of a texture, not total size. Its value can be low like `2048` or high like + * `16384` for 16K support. + * - https://cs.android.com/android/_/android/platform/frameworks/base/+/32c6a7c691b0d91085c1ed13fe6f1c473c94b4c8 + * - https://cs.android.com/android/platform/superproject/+/android-16.0.0_r1:frameworks/base/services/core/java/com/android/server/wallpaper/WallpaperCropper.java;l=461 + * - https://cs.android.com/android/platform/superproject/+/android-16.0.0_r1:frameworks/base/services/core/java/com/android/server/wallpaper/GLHelper.java;l=145 + * - https://developer.android.com/reference/android/opengl/GLES10#GL_MAX_TEXTURE_SIZE + * + * The {@link WallpaperManager#getDesiredMinimumWidth()} and {@link WallpaperManager#getDesiredMinimumHeight()} + * can also be called to get minimum suggested width and height of the wallpaper that an app + * should use when setting the wallpaper. This normally is equal to the width and height of the + * current device display, but the width can be higher than display width if the homescreen is + * scrollable horizontally with multiple pages, in which case the width returned is equal to + * entire workspace width. The launcher apps can provide Android their desired width and height + * dimensions depending on the homescreen pages config by calling + * {@link WallpaperManager#suggestDesiredDimensions(int, int)}, which also ensures that values + * passed are scaled down to `sys.max_texture_size` system property if its set. + * - https://cs.android.com/android/_/android/platform/frameworks/base/+/289c273ec49462c7bfdbf6238e9016936da7307c + * - https://cs.android.com/android/platform/superproject/+/android-16.0.0_r1:frameworks/base/core/java/android/app/WallpaperManager.java;l=2737-2794 + * - https://cs.android.com/android/platform/superproject/+/android-16.0.0_r1:frameworks/base/services/core/java/com/android/server/wallpaper/WallpaperManagerService.java;l=2330-2366 + * - https://cs.android.com/android/platform/superproject/+/android-16.0.0_r1:frameworks/base/services/core/java/com/android/server/wallpaper/WallpaperDisplayHelper.java;l=108-115 + * - https://cs.android.com/android/platform/superproject/+/android-16.0.0_r1:frameworks/base/core/java/android/view/Display.java;l=1052-1063 + * + * If an app specifies `largeHeap=true` in its `AndroidManifest.xml`, then it can be allocated + * larger heap memory to load larger bitmaps maps instead of resulting in an OOM. The Termux app + * does not have it enabled, and hence is more likely to have OOMs when loading larger bitmaps. + * - https://developer.android.com/guide/topics/manifest/application-element#largeHeap + * - https://developer.android.com/topic/performance/memory + */ + public static final int MAX_BITMAP_SIZE = initMaxBitmapSize(); + + protected final TerminalSessionClient mClient; @@ -315,8 +430,15 @@ public static Bitmap resizeBitmap(String logTag, String label, TerminalSessionCl Bitmap newBitmap; try { - int[] pixels = new int[bitmap.getAllocationByteCount()]; + int newBitmapSize = bitmapWidth * bitmapHeight * 4; + if (newBitmapSize < 0 || newBitmapSize > MAX_BITMAP_SIZE) { + Logger.logError(client, logTag, "The new " + label + " bitmap after resize with" + + " width " + bitmapWidth + " and height " + bitmapHeight + + " has size " + newBitmapSize + " greater than max bitmap size " + MAX_BITMAP_SIZE); + return null; + } + 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); 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 fea81faf41..26ce7120c5 100644 --- a/terminal-emulator/src/main/java/com/termux/terminal/TerminalBuffer.java +++ b/terminal-emulator/src/main/java/com/termux/terminal/TerminalBuffer.java @@ -630,13 +630,22 @@ public synchronized void sixelClear() { mTerminalSixel = null; } + /** + * Clears the {@link #mTerminalSixel} by setting it to `null` and logs error. + * Call this on error if further sixel commands/data should be parsed to prevent them from + * printing on terminal, but sixel rendering should be ignored. + */ + public synchronized void sixelIgnore() { + Logger.logError(mClient, LOG_TAG, "Ignoring sixel rendering"); + 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; + sixelIgnore(); return false; } } @@ -648,8 +657,7 @@ public synchronized boolean sixelResize(int sixelWidth, int sixelHeight) { // completely read, but is ignored. if (mTerminalSixel != null) { if (!mTerminalSixel.resize(sixelWidth, sixelHeight)) { - // Ignore further commands/data. - mTerminalSixel = null; + sixelIgnore(); return false; } } 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 64473e019f..aada13b78f 100644 --- a/terminal-emulator/src/main/java/com/termux/terminal/TerminalEmulator.java +++ b/terminal-emulator/src/main/java/com/termux/terminal/TerminalEmulator.java @@ -274,10 +274,15 @@ public final class TerminalEmulator { private static final int TERMINAL_CONTROL_ARGS__INITIAL_CAPACITY = 16; /** - * The max length for {@link #mTerminalControlArgs}. + * The default 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; + private static final int TERMINAL_CONTROL_ARGS__DEFAULT_MAX_LENGTH = 16384; + + /** + * The max length for {@link #mTerminalControlArgs} used by {@link #collectTerminalControlArgs(int)}. + */ + private int mTerminalControlArgsMaxLength = TERMINAL_CONTROL_ARGS__DEFAULT_MAX_LENGTH; /** The terminal control arguments string buffer, like for OSC, DCS, APC commands. */ private StringBuilder mTerminalControlArgs = new StringBuilder(TERMINAL_CONTROL_ARGS__INITIAL_CAPACITY); @@ -1145,7 +1150,7 @@ private void doDcs(final int b) { // 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 + // We wait till at least `mTerminalControlArgsMaxLength / 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. @@ -1156,7 +1161,7 @@ private void doDcs(final int b) { // `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)))) + ((mTerminalControlArgs.length() >= (mTerminalControlArgsMaxLength / 2)) || (mTerminalControlArgs.length() > 1 && mSixelCommandPartNum == 1)))) ) { String dcs = mTerminalControlArgs.toString(); @@ -1411,6 +1416,9 @@ private boolean processSixelDcs(String dcs) { int color; boolean isValidDcs = true; + // FIXME: Use `StringUtils.truncateLogStringWithSnippet()` when its added for logging `dcs` input + // in errors as it may be truncated from end before index if error length is greater than + // `Logger.LOGGER_ENTRY_MAX_SAFE_PAYLOAD`. while (index < dcs.length()) { ch = dcs.charAt(index); @@ -1439,8 +1447,8 @@ else if (ch == '#') { 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); + if (color < 0) { // Overflow. + Logger.logError(mClient, LOG_TAG, "The sixel color command Pc value overflow at index " + index + " of sixel input: " + dcs); isValidDcs = false; break; } @@ -1450,6 +1458,12 @@ else if (ch == '#') { } } + 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; + } + if (!isValidDcs) { break; } @@ -1471,22 +1485,12 @@ else if (ch == '#') { 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; - } + if (args[arg] < 0) { // Overflow. + String argName = ""; + switch (arg) { case 0: argName = "Pu"; break; 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 overflow at index " + index + " of sixel input: " + dcs); + isValidDcs = false; + break; } } else if (ch == ';') { if (arg == 3) { // Pz must not end with a ';'. @@ -1502,9 +1506,37 @@ else if (ch == '#') { index++; } + if (!isValidDcs) { + break; + } + + for (int i = 0; i < args.length; i++) { + if (i == 0) { // Pu must equal 1 or 2. + if ((args[i] != 1 && args[i] != 2)) { + Logger.logError(mClient, LOG_TAG, "The sixel non-basic color command Pu value " + args[i] + " is not 1 or 2 at index " + index + " of sixel input: " + dcs); + isValidDcs = false; + break; + } + } else { + int limit = 100; + if (args[0] == 1 && i == 1) limit = 360; + if (args[i] < 0 || args[i] > limit) { + String argName = ""; + switch (i) { 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[i] + " is not between 0-" + limit + " at index " + index + " of sixel input: " + dcs); + isValidDcs = false; + break; + } + } + } + if (isValidDcs && arg == 3) { // If complete spec is received and is valid. if (args[0] == 2) { // Only RGB is supported. mScreen.sixelSetRGBColor(color, args[1], args[2], args[3]); + } else if (args[0] == 1) { // HLS is not supported. + Logger.logError(mClient, LOG_TAG, "The sixel non-basic color command Pu value " + args[0] + " is not supported at index " + index + " of sixel input: " + dcs); + mScreen.sixelIgnore(); + break; } } else { if (isValidDcs) @@ -1524,9 +1556,8 @@ else if (ch == '!') { 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); + if (repeat < 0) { // Overflow. + Logger.logError(mClient, LOG_TAG, "The sixel repeat command Pn value overflow at index " + index + " of sixel input: " + dcs); isValidDcs = false; break; } @@ -1536,6 +1567,13 @@ else if (ch == '!') { } } + 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); + mScreen.sixelIgnore(); + break; + } + if (!isValidDcs) { break; } @@ -1586,9 +1624,9 @@ else if (ch == '"') { // 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 + // new command starts near `mTerminalControlArgsMaxLength / 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 sixelArgsPartsExpectedLength = (int) ((((double) mTerminalControlArgsMaxLength / 2) + sixelWidth) * 1.02); int sixelArgsExpectedCapacity = Math.min(sixelArgsPartsExpectedLength, sixelArgsExpectedLength); if (sixelArgsExpectedCapacity > SIXEL_ARGS__INITIAL_CAPACITY) { mSixelArgsCapacity = sixelArgsExpectedCapacity; @@ -2654,6 +2692,7 @@ void setOscTypeVariables() { if (mOscType >= 0) { Integer terminalControlArgsCapacity = null; + Integer terminalControlArgsMaxLength = null; switch (mOscType) { case 1337: // iTerm image command sends the base64 encoded image, do not run complex logic for each byte. mIsFastPathOsc = true; @@ -2664,12 +2703,18 @@ void setOscTypeVariables() { // > Older versions of tmux have a limit of 256 bytes for the entire sequence. // - https://iterm2.com/documentation-images.html terminalControlArgsCapacity = 256; + terminalControlArgsMaxLength = TerminalBitmap.MAX_BITMAP_SIZE + + /* `1337;File=inline=1;size=209715200;name=xxxxxxxxxxxxxxxxxxxxxxxx;width=8192px;height=8192px;preserveAspectRatio=1:` */ 150; break; } if (terminalControlArgsCapacity != null) { ensureTerminalControlArgsCapacity(terminalControlArgsCapacity); } + + if (terminalControlArgsMaxLength != null) { + setTerminalControlArgsMaxLength(terminalControlArgsMaxLength); + } } } @@ -3085,7 +3130,9 @@ private int getArg(int index, int defaultValue, boolean treatZeroAsDefault) { /** Collect code point in {@link #mTerminalControlArgs}. */ private boolean collectTerminalControlArgs(int b) { - if (mTerminalControlArgs.length() < TERMINAL_CONTROL_ARGS__MAX_LENGTH) { + // FIXME: Use `Logger.logErrorDebug()` and elsewhere in terminal code when support is added + // to prevent logging potentially private data to logcat unless user has increased log level. + if (mTerminalControlArgs.length() < mTerminalControlArgsMaxLength) { try { // Appending can cause an increase in capacity and cause an OOM. mTerminalControlArgs.appendCodePoint(b); @@ -3095,13 +3142,14 @@ private boolean collectTerminalControlArgs(int b) { 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() + + " args string '" + mTerminalControlArgs.substring(0, Math.min(100, mTerminalControlArgs.length())) + "...' with length " + mTerminalControlArgs.length() + ": " + t.getMessage()); } } else { - Logger.logError(mClient, LOG_TAG, "Terminal control args overflow for" + + Logger.logError(mClient, LOG_TAG, "Terminal control args input will" + + " overflow max args length " + mTerminalControlArgsMaxLength + " for" + " char '" + (char) b + "' (numeric value=" + b + ") and" + - " args string '" + mTerminalControlArgs.substring(0, Math.min(16, mTerminalControlArgs.length())) + "...' with length " + mTerminalControlArgs.length()); + " args string '" + mTerminalControlArgs.substring(0, Math.min(100, mTerminalControlArgs.length())) + "...' with length " + mTerminalControlArgs.length()); } clearTerminalControlArgs(); @@ -3111,6 +3159,8 @@ private boolean collectTerminalControlArgs(int b) { /** Clear {@link #mTerminalControlArgs}. */ private void clearTerminalControlArgs() { + mTerminalControlArgsMaxLength = TERMINAL_CONTROL_ARGS__DEFAULT_MAX_LENGTH; + 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. @@ -3149,6 +3199,18 @@ private void ensureTerminalControlArgsCapacity(int capacity) { mTerminalControlArgs.ensureCapacity(capacity); } + /** + * Set {@link #mTerminalControlArgsMaxLength} in case a command expects a larger input. + * + * @param length The new max length. + */ + private void setTerminalControlArgsMaxLength(int length) { + if (length > 0) { + mTerminalControlArgsMaxLength = length; + } + } + + diff --git a/terminal-emulator/src/main/java/com/termux/terminal/TerminalSixel.java b/terminal-emulator/src/main/java/com/termux/terminal/TerminalSixel.java index d7f438a1fb..fa1340b4b6 100644 --- a/terminal-emulator/src/main/java/com/termux/terminal/TerminalSixel.java +++ b/terminal-emulator/src/main/java/com/termux/terminal/TerminalSixel.java @@ -28,23 +28,20 @@ public class TerminalSixel { 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. + * The amount of extra dimension added when resizing a sixel when receiving more image data. */ - public static final int SIXEL__MAX_BITMAP_DIMENSION = 2048; - - public static final int SIXEL__BITMAP_RESIZE_EXTRA = 100; + public static final int SIXEL__BITMAP_RESIZE_EXTRA_DIMENSION = 100; /** * The max value for the sixel Graphics Repeat Introducer. * + * The value `8192` can support 8K images, see also {@link TerminalBitmap#MAX_BITMAP_SIZE}. + * * 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; + public static final int SIXEL__MAX_REPEAT = 8192; @@ -141,6 +138,11 @@ public boolean readData(int codePoint, int repeat) { return false; } + if (repeat > TerminalSixel.SIXEL__MAX_REPEAT) { + Logger.logError(mClient, LOG_TAG, "The sixel repeat value " + repeat + " is greater than max repeat value " + TerminalSixel.SIXEL__MAX_REPEAT); + 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. @@ -161,15 +163,11 @@ public boolean readData(int codePoint, int repeat) { } if (mBitmap.getWidth() < mCurX + repeat) { - int newBitmapWidth = mCurX + repeat + SIXEL__BITMAP_RESIZE_EXTRA; + int newBitmapWidth = mCurX + repeat + SIXEL__BITMAP_RESIZE_EXTRA_DIMENSION; 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); + Logger.logError(mClient, LOG_TAG, "The new sixel bitmap width overflowed: " + + mCurX + " (cursor x) + " + repeat + " (repeat) + " + SIXEL__BITMAP_RESIZE_EXTRA_DIMENSION); return false; } @@ -181,15 +179,11 @@ public boolean readData(int codePoint, int repeat) { if (mBitmap.getHeight() < mCurY + SIXEL__LINE_LEN) { // Very unlikely to resize both at the same time. - int newBitmapHeight = mCurY + SIXEL__BITMAP_RESIZE_EXTRA; + int newBitmapHeight = mCurY + SIXEL__BITMAP_RESIZE_EXTRA_DIMENSION; 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); + Logger.logError(mClient, LOG_TAG, "The new sixel bitmap height overflowed: " + + mCurY + " (cursor y) + " + SIXEL__BITMAP_RESIZE_EXTRA_DIMENSION); return false; } @@ -251,20 +245,8 @@ public boolean resize(int sixelWidth, int sixelHeight) { 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 mBitmap != null; } return true; From 253eee9ba5412e60911dd1c04697f9fdf7be86a6 Mon Sep 17 00:00:00 2001 From: agnostic-apollo Date: Tue, 14 Apr 2026 16:05:44 +0500 Subject: [PATCH 10/11] Changed: Increase OCS 52 buffer limit to `100KB` for text to set to clipboard --- .../main/java/com/termux/terminal/TerminalEmulator.java | 8 ++++++++ 1 file changed, 8 insertions(+) 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 aada13b78f..5c6dcdec7b 100644 --- a/terminal-emulator/src/main/java/com/termux/terminal/TerminalEmulator.java +++ b/terminal-emulator/src/main/java/com/termux/terminal/TerminalEmulator.java @@ -2694,6 +2694,14 @@ void setOscTypeVariables() { Integer terminalControlArgsCapacity = null; Integer terminalControlArgsMaxLength = null; switch (mOscType) { + case 52: + // Android has a `~100KB` limit for sharing/sending `UTF-16` encoded `String` + // with binder tranasactions, including clipboard, otherwise can result in a + // `TransactionTooLargeException`. + // - https://www.reddit.com/r/tasker/comments/prro8t/autoshare_crashed_when_i_pasted_the_file_path/ + terminalControlArgsMaxLength = (100 * 1024) + + /* `52;Pc;` */ 10; + break; case 1337: // iTerm image command sends the base64 encoded image, do not run complex logic for each byte. mIsFastPathOsc = true; mIgnoreCrLfForOsc = true; From 2151d08496b184980bdf89351213c7617a54e0d3 Mon Sep 17 00:00:00 2001 From: agnostic-apollo Date: Wed, 6 May 2026 02:33:12 +0500 Subject: [PATCH 11/11] Fixed: Allow sixel DCS command start `P3` arg to not end with a semicolon and allow not passing all optional parameters --- .../src/main/java/com/termux/terminal/TerminalEmulator.java | 4 ++++ 1 file changed, 4 insertions(+) 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 5c6dcdec7b..4595dbefd0 100644 --- a/terminal-emulator/src/main/java/com/termux/terminal/TerminalEmulator.java +++ b/terminal-emulator/src/main/java/com/termux/terminal/TerminalEmulator.java @@ -1400,6 +1400,10 @@ private int[] getSixelDcsSetupArgs(String dcs, int index) { } incArg = true; + } else if (ch == 'q') { + // If optional parameters `P1`, `P2` or `P3` are not all passed, or + // a parameter did not end with a `;`, but is followed by `q`. + return args; } else { break; }