From a0023fd2aee37f7e6035348980448e7ef4bbad36 Mon Sep 17 00:00:00 2001 From: WHC Date: Tue, 6 Oct 2026 01:51:31 +0800 Subject: [PATCH] WHC Shell: keyboard-first Android terminal over SSH to local Termux Vendored Termux terminal-emulator/renderer (Apache 2.0) with kitty keyboard protocol, modifyOtherKeys and any-event mouse tracking; sshj client with Ed25519 key login and TOFU host keys; Termux font/colors/cursor sync; mouse selection, context menu, zoom and fullscreen shortcuts. --- .gitignore | 9 + NOTICE | 21 + app/build.gradle | 51 + app/src/main/AndroidManifest.xml | 31 + .../java/com/termux/terminal/ByteQueue.java | 108 + .../java/com/termux/terminal/KeyHandler.java | 373 +++ .../main/java/com/termux/terminal/Logger.java | 80 + .../com/termux/terminal/TerminalBuffer.java | 497 +++ .../termux/terminal/TerminalColorScheme.java | 126 + .../com/termux/terminal/TerminalColors.java | 96 + .../com/termux/terminal/TerminalEmulator.java | 2706 +++++++++++++++++ .../com/termux/terminal/TerminalOutput.java | 32 + .../java/com/termux/terminal/TerminalRow.java | 283 ++ .../terminal/TerminalSessionClient.java | 29 + .../java/com/termux/terminal/TextStyle.java | 90 + .../java/com/termux/terminal/WcWidth.java | 566 ++++ .../com/termux/view/TerminalRenderer.java | 253 ++ .../java/xyz/whcself/whcshell/Appearance.java | 91 + .../java/xyz/whcself/whcshell/KeyEncoder.java | 144 + .../xyz/whcself/whcshell/MainActivity.java | 343 +++ .../java/xyz/whcself/whcshell/SshKeys.java | 69 + .../xyz/whcself/whcshell/TermSession.java | 508 ++++ .../java/xyz/whcself/whcshell/TermView.java | 730 +++++ .../java/xyz/whcself/whcshell/WhcApp.java | 26 + app/src/main/res/drawable/ic_launcher_fg.xml | 19 + .../res/mipmap-anydpi-v26/ic_launcher.xml | 5 + app/src/main/res/values/strings.xml | 4 + app/src/main/res/values/themes.xml | 9 + .../com/termux/terminal/WhcEmulatorTest.java | 118 + .../xyz/whcself/whcshell/KeyEncoderTest.java | 106 + .../xyz/whcself/whcshell/SshKeysTest.java | 58 + build.gradle | 3 + gradle.properties | 3 + settings.gradle | 18 + shell.nix | 27 + third_party/Apache-2.0.txt | 202 ++ third_party/termux-LICENSE.md | 6 + 37 files changed, 7840 insertions(+) create mode 100644 .gitignore create mode 100644 NOTICE create mode 100644 app/build.gradle create mode 100644 app/src/main/AndroidManifest.xml create mode 100644 app/src/main/java/com/termux/terminal/ByteQueue.java create mode 100644 app/src/main/java/com/termux/terminal/KeyHandler.java create mode 100644 app/src/main/java/com/termux/terminal/Logger.java create mode 100644 app/src/main/java/com/termux/terminal/TerminalBuffer.java create mode 100644 app/src/main/java/com/termux/terminal/TerminalColorScheme.java create mode 100644 app/src/main/java/com/termux/terminal/TerminalColors.java create mode 100644 app/src/main/java/com/termux/terminal/TerminalEmulator.java create mode 100644 app/src/main/java/com/termux/terminal/TerminalOutput.java create mode 100644 app/src/main/java/com/termux/terminal/TerminalRow.java create mode 100644 app/src/main/java/com/termux/terminal/TerminalSessionClient.java create mode 100644 app/src/main/java/com/termux/terminal/TextStyle.java create mode 100644 app/src/main/java/com/termux/terminal/WcWidth.java create mode 100644 app/src/main/java/com/termux/view/TerminalRenderer.java create mode 100644 app/src/main/java/xyz/whcself/whcshell/Appearance.java create mode 100644 app/src/main/java/xyz/whcself/whcshell/KeyEncoder.java create mode 100644 app/src/main/java/xyz/whcself/whcshell/MainActivity.java create mode 100644 app/src/main/java/xyz/whcself/whcshell/SshKeys.java create mode 100644 app/src/main/java/xyz/whcself/whcshell/TermSession.java create mode 100644 app/src/main/java/xyz/whcself/whcshell/TermView.java create mode 100644 app/src/main/java/xyz/whcself/whcshell/WhcApp.java create mode 100644 app/src/main/res/drawable/ic_launcher_fg.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 app/src/main/res/values/strings.xml create mode 100644 app/src/main/res/values/themes.xml create mode 100644 app/src/test/java/com/termux/terminal/WhcEmulatorTest.java create mode 100644 app/src/test/java/xyz/whcself/whcshell/KeyEncoderTest.java create mode 100644 app/src/test/java/xyz/whcself/whcshell/SshKeysTest.java create mode 100644 build.gradle create mode 100644 gradle.properties create mode 100644 settings.gradle create mode 100644 shell.nix create mode 100644 third_party/Apache-2.0.txt create mode 100644 third_party/termux-LICENSE.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3d00b57 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +.gradle/ +build/ +app/build/ +local.properties +*.keystore +*.jks +keystore.properties +.idea/ +*.iml diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..1d4bbd3 --- /dev/null +++ b/NOTICE @@ -0,0 +1,21 @@ +WHC Shell +Copyright 2026 whc + +This product includes software from the Termux project +(https://github.com/termux/termux-app, tag v0.118.3), licensed under the +Apache License, Version 2.0 (see third_party/Apache-2.0.txt and +third_party/termux-LICENSE.md): + + app/src/main/java/com/termux/terminal/* (terminal-emulator module) + app/src/main/java/com/termux/view/TerminalRenderer.java (terminal-view module) + +Modifications made for WHC Shell: + - TerminalSessionClient: removed callbacks that take a TerminalSession. + - ByteQueue: made public. + - TerminalRenderer: added getFontLineSpacingAndAscent(). + +The terminal emulator in Termux is itself based on Jack Palevich's +Android-Terminal-Emulator (Apache License, Version 2.0). + +Runtime dependencies (Apache License 2.0 / MIT): + sshj (com.hierynomus:sshj), Bouncy Castle (bcprov, bcpkix), SLF4J. diff --git a/app/build.gradle b/app/build.gradle new file mode 100644 index 0000000..caf964d --- /dev/null +++ b/app/build.gradle @@ -0,0 +1,51 @@ +plugins { + id "com.android.application" +} + +android { + namespace "xyz.whcself.whcshell" + compileSdk 35 + + defaultConfig { + applicationId "xyz.whcself.whcshell" + minSdk 26 + targetSdk 35 + versionCode 1 + versionName "0.1.0" + } + + buildTypes { + release { + minifyEnabled false + } + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } + + testOptions { + unitTests.returnDefaultValues = true + } + + packaging { + resources { + excludes += [ + "META-INF/versions/9/OSGI-INF/MANIFEST.MF", + "META-INF/DEPENDENCIES", + "META-INF/LICENSE*", + "META-INF/NOTICE*", + ] + } + } +} + +dependencies { + implementation "com.hierynomus:sshj:0.39.0" + implementation "org.bouncycastle:bcprov-jdk18on:1.80" + implementation "org.bouncycastle:bcpkix-jdk18on:1.80" + implementation "org.slf4j:slf4j-nop:2.0.16" + + testImplementation "junit:junit:4.13.2" +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..8872de0 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/com/termux/terminal/ByteQueue.java b/app/src/main/java/com/termux/terminal/ByteQueue.java new file mode 100644 index 0000000..568db88 --- /dev/null +++ b/app/src/main/java/com/termux/terminal/ByteQueue.java @@ -0,0 +1,108 @@ +package com.termux.terminal; + +/** A circular byte buffer allowing one producer and one consumer thread. */ +public final class ByteQueue { + + private final byte[] mBuffer; + private int mHead; + private int mStoredBytes; + private boolean mOpen = true; + + public ByteQueue(int size) { + mBuffer = new byte[size]; + } + + public synchronized void close() { + mOpen = false; + notify(); + } + + public synchronized int read(byte[] buffer, boolean block) { + while (mStoredBytes == 0 && mOpen) { + if (block) { + try { + wait(); + } catch (InterruptedException e) { + // Ignore. + } + } else { + return 0; + } + } + if (!mOpen) return -1; + + int totalRead = 0; + int bufferLength = mBuffer.length; + boolean wasFull = bufferLength == mStoredBytes; + int length = buffer.length; + int offset = 0; + while (length > 0 && mStoredBytes > 0) { + int oneRun = Math.min(bufferLength - mHead, mStoredBytes); + int bytesToCopy = Math.min(length, oneRun); + System.arraycopy(mBuffer, mHead, buffer, offset, bytesToCopy); + mHead += bytesToCopy; + if (mHead >= bufferLength) mHead = 0; + mStoredBytes -= bytesToCopy; + length -= bytesToCopy; + offset += bytesToCopy; + totalRead += bytesToCopy; + } + if (wasFull) notify(); + return totalRead; + } + + /** + * Attempt to write the specified portion of the provided buffer to the queue. + *

+ * Returns whether the output was totally written, false if it was closed before. + */ + public boolean write(byte[] buffer, int offset, int lengthToWrite) { + if (lengthToWrite + offset > buffer.length) { + throw new IllegalArgumentException("length + offset > buffer.length"); + } else if (lengthToWrite <= 0) { + throw new IllegalArgumentException("length <= 0"); + } + + final int bufferLength = mBuffer.length; + + synchronized (this) { + while (lengthToWrite > 0) { + while (bufferLength == mStoredBytes && mOpen) { + try { + wait(); + } catch (InterruptedException e) { + // Ignore. + } + } + if (!mOpen) return false; + final boolean wasEmpty = mStoredBytes == 0; + int bytesToWriteBeforeWaiting = Math.min(lengthToWrite, bufferLength - mStoredBytes); + lengthToWrite -= bytesToWriteBeforeWaiting; + + while (bytesToWriteBeforeWaiting > 0) { + int tail = mHead + mStoredBytes; + int oneRun; + if (tail >= bufferLength) { + // Buffer: [.............] + // ________________H_______T + // => + // Buffer: [.............] + // ___________T____H + // onRun= _____----_ + tail = tail - bufferLength; + oneRun = mHead - tail; + } else { + oneRun = bufferLength - tail; + } + int bytesToCopy = Math.min(oneRun, bytesToWriteBeforeWaiting); + System.arraycopy(buffer, offset, mBuffer, tail, bytesToCopy); + offset += bytesToCopy; + bytesToWriteBeforeWaiting -= bytesToCopy; + mStoredBytes += bytesToCopy; + } + if (wasEmpty) notify(); + } + } + return true; + } +} diff --git a/app/src/main/java/com/termux/terminal/KeyHandler.java b/app/src/main/java/com/termux/terminal/KeyHandler.java new file mode 100644 index 0000000..254f5e3 --- /dev/null +++ b/app/src/main/java/com/termux/terminal/KeyHandler.java @@ -0,0 +1,373 @@ +package com.termux.terminal; + +import java.util.HashMap; +import java.util.Map; + +import static android.view.KeyEvent.KEYCODE_BACK; +import static android.view.KeyEvent.KEYCODE_BREAK; +import static android.view.KeyEvent.KEYCODE_DEL; +import static android.view.KeyEvent.KEYCODE_DPAD_CENTER; +import static android.view.KeyEvent.KEYCODE_DPAD_DOWN; +import static android.view.KeyEvent.KEYCODE_DPAD_LEFT; +import static android.view.KeyEvent.KEYCODE_DPAD_RIGHT; +import static android.view.KeyEvent.KEYCODE_DPAD_UP; +import static android.view.KeyEvent.KEYCODE_ENTER; +import static android.view.KeyEvent.KEYCODE_ESCAPE; +import static android.view.KeyEvent.KEYCODE_F1; +import static android.view.KeyEvent.KEYCODE_F10; +import static android.view.KeyEvent.KEYCODE_F11; +import static android.view.KeyEvent.KEYCODE_F12; +import static android.view.KeyEvent.KEYCODE_F2; +import static android.view.KeyEvent.KEYCODE_F3; +import static android.view.KeyEvent.KEYCODE_F4; +import static android.view.KeyEvent.KEYCODE_F5; +import static android.view.KeyEvent.KEYCODE_F6; +import static android.view.KeyEvent.KEYCODE_F7; +import static android.view.KeyEvent.KEYCODE_F8; +import static android.view.KeyEvent.KEYCODE_F9; +import static android.view.KeyEvent.KEYCODE_FORWARD_DEL; +import static android.view.KeyEvent.KEYCODE_INSERT; +import static android.view.KeyEvent.KEYCODE_MOVE_END; +import static android.view.KeyEvent.KEYCODE_MOVE_HOME; +import static android.view.KeyEvent.KEYCODE_NUMPAD_0; +import static android.view.KeyEvent.KEYCODE_NUMPAD_1; +import static android.view.KeyEvent.KEYCODE_NUMPAD_2; +import static android.view.KeyEvent.KEYCODE_NUMPAD_3; +import static android.view.KeyEvent.KEYCODE_NUMPAD_4; +import static android.view.KeyEvent.KEYCODE_NUMPAD_5; +import static android.view.KeyEvent.KEYCODE_NUMPAD_6; +import static android.view.KeyEvent.KEYCODE_NUMPAD_7; +import static android.view.KeyEvent.KEYCODE_NUMPAD_8; +import static android.view.KeyEvent.KEYCODE_NUMPAD_9; +import static android.view.KeyEvent.KEYCODE_NUMPAD_ADD; +import static android.view.KeyEvent.KEYCODE_NUMPAD_COMMA; +import static android.view.KeyEvent.KEYCODE_NUMPAD_DIVIDE; +import static android.view.KeyEvent.KEYCODE_NUMPAD_DOT; +import static android.view.KeyEvent.KEYCODE_NUMPAD_ENTER; +import static android.view.KeyEvent.KEYCODE_NUMPAD_EQUALS; +import static android.view.KeyEvent.KEYCODE_NUMPAD_MULTIPLY; +import static android.view.KeyEvent.KEYCODE_NUMPAD_SUBTRACT; +import static android.view.KeyEvent.KEYCODE_NUM_LOCK; +import static android.view.KeyEvent.KEYCODE_PAGE_DOWN; +import static android.view.KeyEvent.KEYCODE_PAGE_UP; +import static android.view.KeyEvent.KEYCODE_SPACE; +import static android.view.KeyEvent.KEYCODE_SYSRQ; +import static android.view.KeyEvent.KEYCODE_TAB; + +public final class KeyHandler { + + public static final int KEYMOD_ALT = 0x80000000; + public static final int KEYMOD_CTRL = 0x40000000; + public static final int KEYMOD_SHIFT = 0x20000000; + public static final int KEYMOD_NUM_LOCK = 0x10000000; + + private static final Map TERMCAP_TO_KEYCODE = new HashMap<>(); + + static { + // terminfo: http://pubs.opengroup.org/onlinepubs/7990989799/xcurses/terminfo.html + // termcap: http://man7.org/linux/man-pages/man5/termcap.5.html + TERMCAP_TO_KEYCODE.put("%i", KEYMOD_SHIFT | KEYCODE_DPAD_RIGHT); + TERMCAP_TO_KEYCODE.put("#2", KEYMOD_SHIFT | KEYCODE_MOVE_HOME); // Shifted home + TERMCAP_TO_KEYCODE.put("#4", KEYMOD_SHIFT | KEYCODE_DPAD_LEFT); + TERMCAP_TO_KEYCODE.put("*7", KEYMOD_SHIFT | KEYCODE_MOVE_END); // Shifted end key + + TERMCAP_TO_KEYCODE.put("k1", KEYCODE_F1); + TERMCAP_TO_KEYCODE.put("k2", KEYCODE_F2); + TERMCAP_TO_KEYCODE.put("k3", KEYCODE_F3); + TERMCAP_TO_KEYCODE.put("k4", KEYCODE_F4); + TERMCAP_TO_KEYCODE.put("k5", KEYCODE_F5); + TERMCAP_TO_KEYCODE.put("k6", KEYCODE_F6); + TERMCAP_TO_KEYCODE.put("k7", KEYCODE_F7); + TERMCAP_TO_KEYCODE.put("k8", KEYCODE_F8); + TERMCAP_TO_KEYCODE.put("k9", KEYCODE_F9); + TERMCAP_TO_KEYCODE.put("k;", KEYCODE_F10); + TERMCAP_TO_KEYCODE.put("F1", KEYCODE_F11); + TERMCAP_TO_KEYCODE.put("F2", KEYCODE_F12); + TERMCAP_TO_KEYCODE.put("F3", KEYMOD_SHIFT | KEYCODE_F1); + TERMCAP_TO_KEYCODE.put("F4", KEYMOD_SHIFT | KEYCODE_F2); + TERMCAP_TO_KEYCODE.put("F5", KEYMOD_SHIFT | KEYCODE_F3); + TERMCAP_TO_KEYCODE.put("F6", KEYMOD_SHIFT | KEYCODE_F4); + TERMCAP_TO_KEYCODE.put("F7", KEYMOD_SHIFT | KEYCODE_F5); + TERMCAP_TO_KEYCODE.put("F8", KEYMOD_SHIFT | KEYCODE_F6); + TERMCAP_TO_KEYCODE.put("F9", KEYMOD_SHIFT | KEYCODE_F7); + TERMCAP_TO_KEYCODE.put("FA", KEYMOD_SHIFT | KEYCODE_F8); + TERMCAP_TO_KEYCODE.put("FB", KEYMOD_SHIFT | KEYCODE_F9); + TERMCAP_TO_KEYCODE.put("FC", KEYMOD_SHIFT | KEYCODE_F10); + TERMCAP_TO_KEYCODE.put("FD", KEYMOD_SHIFT | KEYCODE_F11); + TERMCAP_TO_KEYCODE.put("FE", KEYMOD_SHIFT | KEYCODE_F12); + + TERMCAP_TO_KEYCODE.put("kb", KEYCODE_DEL); // backspace key + + TERMCAP_TO_KEYCODE.put("kd", KEYCODE_DPAD_DOWN); // terminfo=kcud1, down-arrow key + TERMCAP_TO_KEYCODE.put("kh", KEYCODE_MOVE_HOME); + TERMCAP_TO_KEYCODE.put("kl", KEYCODE_DPAD_LEFT); + TERMCAP_TO_KEYCODE.put("kr", KEYCODE_DPAD_RIGHT); + + // K1=Upper left of keypad: + // t_K1 keypad home key + // t_K3 keypad page-up key + // t_K4 keypad end key + // t_K5 keypad page-down key + TERMCAP_TO_KEYCODE.put("K1", KEYCODE_MOVE_HOME); + TERMCAP_TO_KEYCODE.put("K3", KEYCODE_PAGE_UP); + TERMCAP_TO_KEYCODE.put("K4", KEYCODE_MOVE_END); + TERMCAP_TO_KEYCODE.put("K5", KEYCODE_PAGE_DOWN); + + TERMCAP_TO_KEYCODE.put("ku", KEYCODE_DPAD_UP); + + TERMCAP_TO_KEYCODE.put("kB", KEYMOD_SHIFT | KEYCODE_TAB); // termcap=kB, terminfo=kcbt: Back-tab + TERMCAP_TO_KEYCODE.put("kD", KEYCODE_FORWARD_DEL); // terminfo=kdch1, delete-character key + TERMCAP_TO_KEYCODE.put("kDN", KEYMOD_SHIFT | KEYCODE_DPAD_DOWN); // non-standard shifted arrow down + TERMCAP_TO_KEYCODE.put("kF", KEYMOD_SHIFT | KEYCODE_DPAD_DOWN); // terminfo=kind, scroll-forward key + TERMCAP_TO_KEYCODE.put("kI", KEYCODE_INSERT); + TERMCAP_TO_KEYCODE.put("kN", KEYCODE_PAGE_UP); + TERMCAP_TO_KEYCODE.put("kP", KEYCODE_PAGE_DOWN); + TERMCAP_TO_KEYCODE.put("kR", KEYMOD_SHIFT | KEYCODE_DPAD_UP); // terminfo=kri, scroll-backward key + TERMCAP_TO_KEYCODE.put("kUP", KEYMOD_SHIFT | KEYCODE_DPAD_UP); // non-standard shifted up + + TERMCAP_TO_KEYCODE.put("@7", KEYCODE_MOVE_END); + TERMCAP_TO_KEYCODE.put("@8", KEYCODE_NUMPAD_ENTER); + } + + static String getCodeFromTermcap(String termcap, boolean cursorKeysApplication, boolean keypadApplication) { + Integer keyCodeAndMod = TERMCAP_TO_KEYCODE.get(termcap); + if (keyCodeAndMod == null) return null; + int keyCode = keyCodeAndMod; + int keyMod = 0; + if ((keyCode & KEYMOD_SHIFT) != 0) { + keyMod |= KEYMOD_SHIFT; + keyCode &= ~KEYMOD_SHIFT; + } + if ((keyCode & KEYMOD_CTRL) != 0) { + keyMod |= KEYMOD_CTRL; + keyCode &= ~KEYMOD_CTRL; + } + if ((keyCode & KEYMOD_ALT) != 0) { + keyMod |= KEYMOD_ALT; + keyCode &= ~KEYMOD_ALT; + } + if ((keyCode & KEYMOD_NUM_LOCK) != 0) { + keyMod |= KEYMOD_NUM_LOCK; + keyCode &= ~KEYMOD_NUM_LOCK; + } + return getCode(keyCode, keyMod, cursorKeysApplication, keypadApplication); + } + + public static String getCode(int keyCode, int keyMode, boolean cursorApp, boolean keypadApplication) { + boolean numLockOn = (keyMode & KEYMOD_NUM_LOCK) != 0; + keyMode &= ~KEYMOD_NUM_LOCK; + switch (keyCode) { + case KEYCODE_DPAD_CENTER: + return "\015"; + + case KEYCODE_DPAD_UP: + return (keyMode == 0) ? (cursorApp ? "\033OA" : "\033[A") : transformForModifiers("\033[1", keyMode, 'A'); + case KEYCODE_DPAD_DOWN: + return (keyMode == 0) ? (cursorApp ? "\033OB" : "\033[B") : transformForModifiers("\033[1", keyMode, 'B'); + case KEYCODE_DPAD_RIGHT: + return (keyMode == 0) ? (cursorApp ? "\033OC" : "\033[C") : transformForModifiers("\033[1", keyMode, 'C'); + case KEYCODE_DPAD_LEFT: + return (keyMode == 0) ? (cursorApp ? "\033OD" : "\033[D") : transformForModifiers("\033[1", keyMode, 'D'); + + case KEYCODE_MOVE_HOME: + // Note that KEYCODE_HOME is handled by the system and never delivered to applications. + // On a Logitech k810 keyboard KEYCODE_MOVE_HOME is sent by FN+LeftArrow. + return (keyMode == 0) ? (cursorApp ? "\033OH" : "\033[H") : transformForModifiers("\033[1", keyMode, 'H'); + case KEYCODE_MOVE_END: + return (keyMode == 0) ? (cursorApp ? "\033OF" : "\033[F") : transformForModifiers("\033[1", keyMode, 'F'); + + // An xterm can send function keys F1 to F4 in two modes: vt100 compatible or + // not. Because Vim may not know what the xterm is sending, both types of keys + // are recognized. The same happens for the and keys. + // normal vt100 ~ + // t_k1 [11~ OP *-xterm* + // t_k2 [12~ OQ *-xterm* + // t_k3 [13~ OR *-xterm* + // t_k4 [14~ OS *-xterm* + // t_kh [7~ OH *-xterm* + // t_@7 [4~ OF *-xterm* + case KEYCODE_F1: + return (keyMode == 0) ? "\033OP" : transformForModifiers("\033[1", keyMode, 'P'); + case KEYCODE_F2: + return (keyMode == 0) ? "\033OQ" : transformForModifiers("\033[1", keyMode, 'Q'); + case KEYCODE_F3: + return (keyMode == 0) ? "\033OR" : transformForModifiers("\033[1", keyMode, 'R'); + case KEYCODE_F4: + return (keyMode == 0) ? "\033OS" : transformForModifiers("\033[1", keyMode, 'S'); + case KEYCODE_F5: + return transformForModifiers("\033[15", keyMode, '~'); + case KEYCODE_F6: + return transformForModifiers("\033[17", keyMode, '~'); + case KEYCODE_F7: + return transformForModifiers("\033[18", keyMode, '~'); + case KEYCODE_F8: + return transformForModifiers("\033[19", keyMode, '~'); + case KEYCODE_F9: + return transformForModifiers("\033[20", keyMode, '~'); + case KEYCODE_F10: + return transformForModifiers("\033[21", keyMode, '~'); + case KEYCODE_F11: + return transformForModifiers("\033[23", keyMode, '~'); + case KEYCODE_F12: + return transformForModifiers("\033[24", keyMode, '~'); + + case KEYCODE_SYSRQ: + return "\033[32~"; // Sys Request / Print + // Is this Scroll lock? case Cancel: return "\033[33~"; + case KEYCODE_BREAK: + return "\033[34~"; // Pause/Break + + case KEYCODE_ESCAPE: + case KEYCODE_BACK: + return "\033"; + + case KEYCODE_INSERT: + return transformForModifiers("\033[2", keyMode, '~'); + case KEYCODE_FORWARD_DEL: + return transformForModifiers("\033[3", keyMode, '~'); + + case KEYCODE_PAGE_UP: + return "\033[5~"; + case KEYCODE_PAGE_DOWN: + return "\033[6~"; + case KEYCODE_DEL: + String prefix = ((keyMode & KEYMOD_ALT) == 0) ? "" : "\033"; + // Just do what xterm and gnome-terminal does: + return prefix + (((keyMode & KEYMOD_CTRL) == 0) ? "\u007F" : "\u0008"); + case KEYCODE_NUM_LOCK: + if (keypadApplication) { + return "\033OP"; + } else { + return null; + } + case KEYCODE_SPACE: + // If ctrl is not down, return null so that it goes through normal input processing (which may e.g. cause a + // combining accent to be written): + return ((keyMode & KEYMOD_CTRL) == 0) ? null : "\0"; + case KEYCODE_TAB: + // This is back-tab when shifted: + return (keyMode & KEYMOD_SHIFT) == 0 ? "\011" : "\033[Z"; + case KEYCODE_ENTER: + return ((keyMode & KEYMOD_ALT) == 0) ? "\r" : "\033\r"; + + case KEYCODE_NUMPAD_ENTER: + return keypadApplication ? transformForModifiers("\033O", keyMode, 'M') : "\n"; + case KEYCODE_NUMPAD_MULTIPLY: + return keypadApplication ? transformForModifiers("\033O", keyMode, 'j') : "*"; + case KEYCODE_NUMPAD_ADD: + return keypadApplication ? transformForModifiers("\033O", keyMode, 'k') : "+"; + case KEYCODE_NUMPAD_COMMA: + return ","; + case KEYCODE_NUMPAD_DOT: + if (numLockOn) { + return keypadApplication ? "\033On" : "."; + } else { + // DELETE + return transformForModifiers("\033[3", keyMode, '~'); + } + case KEYCODE_NUMPAD_SUBTRACT: + return keypadApplication ? transformForModifiers("\033O", keyMode, 'm') : "-"; + case KEYCODE_NUMPAD_DIVIDE: + return keypadApplication ? transformForModifiers("\033O", keyMode, 'o') : "/"; + case KEYCODE_NUMPAD_0: + if (numLockOn) { + return keypadApplication ? transformForModifiers("\033O", keyMode, 'p') : "0"; + } else { + // INSERT + return transformForModifiers("\033[2", keyMode, '~'); + } + case KEYCODE_NUMPAD_1: + if (numLockOn) { + return keypadApplication ? transformForModifiers("\033O", keyMode, 'q') : "1"; + } else { + // END + return (keyMode == 0) ? (cursorApp ? "\033OF" : "\033[F") : transformForModifiers("\033[1", keyMode, 'F'); + } + case KEYCODE_NUMPAD_2: + if (numLockOn) { + return keypadApplication ? transformForModifiers("\033O", keyMode, 'r') : "2"; + } else { + // DOWN + return (keyMode == 0) ? (cursorApp ? "\033OB" : "\033[B") : transformForModifiers("\033[1", keyMode, 'B'); + } + case KEYCODE_NUMPAD_3: + if (numLockOn) { + return keypadApplication ? transformForModifiers("\033O", keyMode, 's') : "3"; + } else { + // PGDN + return "\033[6~"; + } + case KEYCODE_NUMPAD_4: + if (numLockOn) { + return keypadApplication ? transformForModifiers("\033O", keyMode, 't') : "4"; + } else { + // LEFT + return (keyMode == 0) ? (cursorApp ? "\033OD" : "\033[D") : transformForModifiers("\033[1", keyMode, 'D'); + } + case KEYCODE_NUMPAD_5: + return keypadApplication ? transformForModifiers("\033O", keyMode, 'u') : "5"; + case KEYCODE_NUMPAD_6: + if (numLockOn) { + return keypadApplication ? transformForModifiers("\033O", keyMode, 'v') : "6"; + } else { + // RIGHT + return (keyMode == 0) ? (cursorApp ? "\033OC" : "\033[C") : transformForModifiers("\033[1", keyMode, 'C'); + } + case KEYCODE_NUMPAD_7: + if (numLockOn) { + return keypadApplication ? transformForModifiers("\033O", keyMode, 'w') : "7"; + } else { + // HOME + return (keyMode == 0) ? (cursorApp ? "\033OH" : "\033[H") : transformForModifiers("\033[1", keyMode, 'H'); + } + case KEYCODE_NUMPAD_8: + if (numLockOn) { + return keypadApplication ? transformForModifiers("\033O", keyMode, 'x') : "8"; + } else { + // UP + return (keyMode == 0) ? (cursorApp ? "\033OA" : "\033[A") : transformForModifiers("\033[1", keyMode, 'A'); + } + case KEYCODE_NUMPAD_9: + if (numLockOn) { + return keypadApplication ? transformForModifiers("\033O", keyMode, 'y') : "9"; + } else { + // PGUP + return "\033[5~"; + } + case KEYCODE_NUMPAD_EQUALS: + return keypadApplication ? transformForModifiers("\033O", keyMode, 'X') : "="; + } + + return null; + } + + private static String transformForModifiers(String start, int keymod, char lastChar) { + int modifier; + switch (keymod) { + case KEYMOD_SHIFT: + modifier = 2; + break; + case KEYMOD_ALT: + modifier = 3; + break; + case (KEYMOD_SHIFT | KEYMOD_ALT): + modifier = 4; + break; + case KEYMOD_CTRL: + modifier = 5; + break; + case KEYMOD_SHIFT | KEYMOD_CTRL: + modifier = 6; + break; + case KEYMOD_ALT | KEYMOD_CTRL: + modifier = 7; + break; + case KEYMOD_SHIFT | KEYMOD_ALT | KEYMOD_CTRL: + modifier = 8; + break; + default: + return start + lastChar; + } + return start + (";" + modifier) + lastChar; + } +} diff --git a/app/src/main/java/com/termux/terminal/Logger.java b/app/src/main/java/com/termux/terminal/Logger.java new file mode 100644 index 0000000..d4d502e --- /dev/null +++ b/app/src/main/java/com/termux/terminal/Logger.java @@ -0,0 +1,80 @@ +package com.termux.terminal; + +import android.util.Log; + +import java.io.IOException; +import java.io.PrintWriter; +import java.io.StringWriter; + +public class Logger { + + public static void logError(TerminalSessionClient client, String logTag, String message) { + if (client != null) + client.logError(logTag, message); + else + Log.e(logTag, message); + } + + public static void logWarn(TerminalSessionClient client, String logTag, String message) { + if (client != null) + client.logWarn(logTag, message); + else + Log.w(logTag, message); + } + + public static void logInfo(TerminalSessionClient client, String logTag, String message) { + if (client != null) + client.logInfo(logTag, message); + else + Log.i(logTag, message); + } + + public static void logDebug(TerminalSessionClient client, String logTag, String message) { + if (client != null) + client.logDebug(logTag, message); + else + Log.d(logTag, message); + } + + public static void logVerbose(TerminalSessionClient client, String logTag, String message) { + if (client != null) + client.logVerbose(logTag, message); + else + Log.v(logTag, message); + } + + public static void logStackTraceWithMessage(TerminalSessionClient client, String tag, String message, Throwable throwable) { + logError(client, tag, getMessageAndStackTraceString(message, throwable)); + } + + public static String getMessageAndStackTraceString(String message, Throwable throwable) { + if (message == null && throwable == null) + return null; + else if (message != null && throwable != null) + return message + ":\n" + getStackTraceString(throwable); + else if (throwable == null) + return message; + else + return getStackTraceString(throwable); + } + + public static String getStackTraceString(Throwable throwable) { + if (throwable == null) return null; + + String stackTraceString = null; + + try { + StringWriter errors = new StringWriter(); + PrintWriter pw = new PrintWriter(errors); + throwable.printStackTrace(pw); + pw.close(); + stackTraceString = errors.toString(); + errors.close(); + } catch (IOException e) { + e.printStackTrace(); + } + + return stackTraceString; + } + +} diff --git a/app/src/main/java/com/termux/terminal/TerminalBuffer.java b/app/src/main/java/com/termux/terminal/TerminalBuffer.java new file mode 100644 index 0000000..21d6518 --- /dev/null +++ b/app/src/main/java/com/termux/terminal/TerminalBuffer.java @@ -0,0 +1,497 @@ +package com.termux.terminal; + +import java.util.Arrays; + +/** + * 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 TerminalBuffer { + + TerminalRow[] mLines; + /** The length of {@link #mLines}. */ + int mTotalRows; + /** The number of rows and columns visible on the screen. */ + int mScreenRows, mColumns; + /** The number of rows kept in history. */ + private int mActiveTranscriptRows = 0; + /** The index in the circular buffer where the visible screen starts. */ + private int mScreenFirstRow = 0; + + /** + * Create a transcript screen. + * + * @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) { + mColumns = columns; + mTotalRows = totalRows; + mScreenRows = screenRows; + mLines = new TerminalRow[totalRows]; + + blockSet(0, 0, columns, screenRows, ' ', TextStyle.NORMAL); + } + + public String getTranscriptText() { + return getSelectedText(0, -getActiveTranscriptRows(), mColumns, mScreenRows).trim(); + } + + public String getTranscriptTextWithoutJoinedLines() { + return getSelectedText(0, -getActiveTranscriptRows(), mColumns, mScreenRows, false).trim(); + } + + public String getTranscriptTextWithFullLinesJoined() { + return getSelectedText(0, -getActiveTranscriptRows(), mColumns, mScreenRows, true, true).trim(); + } + + public String getSelectedText(int selX1, int selY1, int selX2, int selY2) { + return getSelectedText(selX1, selY1, selX2, selY2, true); + } + + public String getSelectedText(int selX1, int selY1, int selX2, int selY2, boolean joinBackLines) { + return getSelectedText(selX1, selY1, selX2, selY2, joinBackLines, false); + } + + public String getSelectedText(int selX1, int selY1, int selX2, int selY2, boolean joinBackLines, boolean joinFullLines) { + final StringBuilder builder = new StringBuilder(); + final int columns = mColumns; + + if (selY1 < -getActiveTranscriptRows()) selY1 = -getActiveTranscriptRows(); + if (selY2 >= mScreenRows) selY2 = mScreenRows - 1; + + for (int row = selY1; row <= selY2; row++) { + int x1 = (row == selY1) ? selX1 : 0; + int x2; + if (row == selY2) { + x2 = selX2 + 1; + if (x2 > columns) x2 = columns; + } else { + x2 = columns; + } + TerminalRow lineObject = mLines[externalToInternalRow(row)]; + int x1Index = lineObject.findStartOfColumn(x1); + int x2Index = (x2 < mColumns) ? lineObject.findStartOfColumn(x2) : lineObject.getSpaceUsed(); + if (x2Index == x1Index) { + // Selected the start of a wide character. + x2Index = lineObject.findStartOfColumn(x2 + 1); + } + char[] line = lineObject.mText; + int lastPrintingCharIndex = -1; + int i; + boolean rowLineWrap = getLineWrap(row); + if (rowLineWrap && x2 == columns) { + // If the line was wrapped, we shouldn't lose trailing space: + lastPrintingCharIndex = x2Index - 1; + } else { + for (i = x1Index; i < x2Index; ++i) { + char c = line[i]; + if (c != ' ') lastPrintingCharIndex = i; + } + } + + int len = lastPrintingCharIndex - x1Index + 1; + if (lastPrintingCharIndex != -1 && len > 0) + builder.append(line, x1Index, len); + + boolean lineFillsWidth = lastPrintingCharIndex == x2Index - 1; + if ((!joinBackLines || !rowLineWrap) && (!joinFullLines || !lineFillsWidth) + && row < selY2 && row < mScreenRows - 1) builder.append('\n'); + } + return builder.toString(); + } + + public String getWordAtLocation(int x, int y) { + // Set y1 and y2 to the lines where the wrapped line starts and ends. + // I.e. if a line that is wrapped to 3 lines starts at line 4, and this + // is called with y=5, then y1 would be set to 4 and y2 would be set to 6. + int y1 = y; + int y2 = y; + while (y1 > 0 && !getSelectedText(0, y1 - 1, mColumns, y, true, true).contains("\n")) { + y1--; + } + while (y2 < mScreenRows && !getSelectedText(0, y, mColumns, y2 + 1, true, true).contains("\n")) { + y2++; + } + + // Get the text for the whole wrapped line + String text = getSelectedText(0, y1, mColumns, y2, true, true); + // The index of x in text + int textOffset = (y - y1) * mColumns + x; + + if (textOffset >= text.length()) { + // The click was to the right of the last word on the line, so + // there's no word to return + return ""; + } + + // Set x1 and x2 to the indices of the last space before x and the + // first space after x in text respectively + int x1 = text.lastIndexOf(' ', textOffset); + int x2 = text.indexOf(' ', textOffset); + if (x2 == -1) { + x2 = text.length(); + } + + if (x1 == x2) { + // The click was on a space, so there's no word to return + return ""; + } + return text.substring(x1 + 1, x2); + } + + public int getActiveTranscriptRows() { + return mActiveTranscriptRows; + } + + public int getActiveRows() { + return mActiveTranscriptRows + mScreenRows; + } + + /** + * Convert a row value from the public external coordinate system to our internal private coordinate system. + * + *

+     * - External coordinate system: -mActiveTranscriptRows to mScreenRows-1, with the screen being 0..mScreenRows-1.
+     * - Internal coordinate system: the mScreenRows lines starting at mScreenFirstRow comprise the screen, while the
+     *   mActiveTranscriptRows lines ending at mScreenFirstRow-1 form the transcript (as a circular buffer).
+     *
+     * External ↔ Internal:
+     *
+     * [ ...                            ]     [ ...                                     ]
+     * [ -mActiveTranscriptRows         ]     [ mScreenFirstRow - mActiveTranscriptRows ]
+     * [ ...                            ]     [ ...                                     ]
+     * [ 0 (visible screen starts here) ]  ↔  [ mScreenFirstRow                         ]
+     * [ ...                            ]     [ ...                                     ]
+     * [ mScreenRows-1                  ]     [ mScreenFirstRow + mScreenRows-1         ]
+     * 
+ * + * @param externalRow a row in the external coordinate system. + * @return The row corresponding to the input argument in the private coordinate system. + */ + public int externalToInternalRow(int externalRow) { + if (externalRow < -mActiveTranscriptRows || externalRow > mScreenRows) + throw new IllegalArgumentException("extRow=" + externalRow + ", mScreenRows=" + mScreenRows + ", mActiveTranscriptRows=" + mActiveTranscriptRows); + final int internalRow = mScreenFirstRow + externalRow; + return (internalRow < 0) ? (mTotalRows + internalRow) : (internalRow % mTotalRows); + } + + public void setLineWrap(int row) { + mLines[externalToInternalRow(row)].mLineWrap = true; + } + + public boolean getLineWrap(int row) { + return mLines[externalToInternalRow(row)].mLineWrap; + } + + public void clearLineWrap(int row) { + mLines[externalToInternalRow(row)].mLineWrap = false; + } + + /** + * Resize the screen which this transcript backs. Currently, this only works if the number of columns does not + * change or the rows expand (that is, it only works when shrinking the number of rows). + * + * @param newColumns The number of columns the screen should have. + * @param newRows The number of rows the screen should have. + * @param cursor An int[2] containing the (column, row) cursor location. + */ + public void resize(int newColumns, int newRows, int newTotalRows, int[] cursor, long currentStyle, boolean altScreen) { + // newRows > mTotalRows should not normally happen since mTotalRows is TRANSCRIPT_ROWS (10000): + if (newColumns == mColumns && newRows <= mTotalRows) { + // Fast resize where just the rows changed. + int shiftDownOfTopRow = mScreenRows - newRows; + if (shiftDownOfTopRow > 0 && shiftDownOfTopRow < mScreenRows) { + // Shrinking. Check if we can skip blank rows at bottom below cursor. + for (int i = mScreenRows - 1; i > 0; i--) { + if (cursor[1] >= i) break; + int r = externalToInternalRow(i); + if (mLines[r] == null || mLines[r].isBlank()) { + if (--shiftDownOfTopRow == 0) break; + } + } + } else if (shiftDownOfTopRow < 0) { + // Negative shift down = expanding. Only move screen up if there is transcript to show: + int actualShift = Math.max(shiftDownOfTopRow, -mActiveTranscriptRows); + if (shiftDownOfTopRow != actualShift) { + // The new lines revealed by the resizing are not all from the transcript. Blank the below ones. + for (int i = 0; i < actualShift - shiftDownOfTopRow; i++) + allocateFullLineIfNecessary((mScreenFirstRow + mScreenRows + i) % mTotalRows).clear(currentStyle); + shiftDownOfTopRow = actualShift; + } + } + mScreenFirstRow += shiftDownOfTopRow; + mScreenFirstRow = (mScreenFirstRow < 0) ? (mScreenFirstRow + mTotalRows) : (mScreenFirstRow % mTotalRows); + mTotalRows = newTotalRows; + mActiveTranscriptRows = altScreen ? 0 : Math.max(0, mActiveTranscriptRows + shiftDownOfTopRow); + cursor[1] -= shiftDownOfTopRow; + mScreenRows = newRows; + } else { + // Copy away old state and update new: + TerminalRow[] oldLines = mLines; + mLines = new TerminalRow[newTotalRows]; + for (int i = 0; i < newTotalRows; i++) + mLines[i] = new TerminalRow(newColumns, currentStyle); + + final int oldActiveTranscriptRows = mActiveTranscriptRows; + final int oldScreenFirstRow = mScreenFirstRow; + final int oldScreenRows = mScreenRows; + final int oldTotalRows = mTotalRows; + mTotalRows = newTotalRows; + mScreenRows = newRows; + mActiveTranscriptRows = mScreenFirstRow = 0; + mColumns = newColumns; + + int newCursorRow = -1; + int newCursorColumn = -1; + int oldCursorRow = cursor[1]; + int oldCursorColumn = cursor[0]; + boolean newCursorPlaced = false; + + int currentOutputExternalRow = 0; + int currentOutputExternalColumn = 0; + + // Loop over every character in the initial state. + // Blank lines should be skipped only if at end of transcript (just as is done in the "fast" resize), so we + // keep track how many blank lines we have skipped if we later on find a non-blank line. + int skippedBlankLines = 0; + for (int externalOldRow = -oldActiveTranscriptRows; externalOldRow < oldScreenRows; externalOldRow++) { + // Do what externalToInternalRow() does but for the old state: + int internalOldRow = oldScreenFirstRow + externalOldRow; + internalOldRow = (internalOldRow < 0) ? (oldTotalRows + internalOldRow) : (internalOldRow % oldTotalRows); + + TerminalRow oldLine = oldLines[internalOldRow]; + boolean cursorAtThisRow = externalOldRow == oldCursorRow; + // The cursor may only be on a non-null line, which we should not skip: + if (oldLine == null || (!(!newCursorPlaced && cursorAtThisRow)) && oldLine.isBlank()) { + skippedBlankLines++; + continue; + } else if (skippedBlankLines > 0) { + // After skipping some blank lines we encounter a non-blank line. Insert the skipped blank lines. + for (int i = 0; i < skippedBlankLines; i++) { + if (currentOutputExternalRow == mScreenRows - 1) { + scrollDownOneLine(0, mScreenRows, currentStyle); + } else { + currentOutputExternalRow++; + } + currentOutputExternalColumn = 0; + } + skippedBlankLines = 0; + } + + int lastNonSpaceIndex = 0; + boolean justToCursor = false; + if (cursorAtThisRow || oldLine.mLineWrap) { + // Take the whole line, either because of cursor on it, or if line wrapping. + lastNonSpaceIndex = oldLine.getSpaceUsed(); + if (cursorAtThisRow) justToCursor = true; + } else { + for (int i = 0; i < oldLine.getSpaceUsed(); i++) + // NEWLY INTRODUCED BUG! Should not index oldLine.mStyle with char indices + if (oldLine.mText[i] != ' '/* || oldLine.mStyle[i] != currentStyle */) + lastNonSpaceIndex = i + 1; + } + + int currentOldCol = 0; + long styleAtCol = 0; + for (int i = 0; i < lastNonSpaceIndex; i++) { + // Note that looping over java character, not cells. + char c = oldLine.mText[i]; + int codePoint = (Character.isHighSurrogate(c)) ? Character.toCodePoint(c, oldLine.mText[++i]) : c; + int displayWidth = WcWidth.width(codePoint); + // Use the last style if this is a zero-width character: + if (displayWidth > 0) styleAtCol = oldLine.getStyle(currentOldCol); + + // Line wrap as necessary: + if (currentOutputExternalColumn + displayWidth > mColumns) { + setLineWrap(currentOutputExternalRow); + if (currentOutputExternalRow == mScreenRows - 1) { + if (newCursorPlaced) newCursorRow--; + scrollDownOneLine(0, mScreenRows, currentStyle); + } else { + currentOutputExternalRow++; + } + currentOutputExternalColumn = 0; + } + + int offsetDueToCombiningChar = ((displayWidth <= 0 && currentOutputExternalColumn > 0) ? 1 : 0); + int outputColumn = currentOutputExternalColumn - offsetDueToCombiningChar; + setChar(outputColumn, currentOutputExternalRow, codePoint, styleAtCol); + + if (displayWidth > 0) { + if (oldCursorRow == externalOldRow && oldCursorColumn == currentOldCol) { + newCursorColumn = currentOutputExternalColumn; + newCursorRow = currentOutputExternalRow; + newCursorPlaced = true; + } + currentOldCol += displayWidth; + currentOutputExternalColumn += displayWidth; + if (justToCursor && newCursorPlaced) break; + } + } + // Old row has been copied. Check if we need to insert newline if old line was not wrapping: + if (externalOldRow != (oldScreenRows - 1) && !oldLine.mLineWrap) { + if (currentOutputExternalRow == mScreenRows - 1) { + if (newCursorPlaced) newCursorRow--; + scrollDownOneLine(0, mScreenRows, currentStyle); + } else { + currentOutputExternalRow++; + } + currentOutputExternalColumn = 0; + } + } + + cursor[0] = newCursorColumn; + cursor[1] = newCursorRow; + } + + // Handle cursor scrolling off screen: + if (cursor[0] < 0 || cursor[1] < 0) cursor[0] = cursor[1] = 0; + } + + /** + * Block copy lines and associated metadata from one location to another in the circular buffer, taking wraparound + * into account. + * + * @param srcInternal The first line to be copied. + * @param len The number of lines to be copied. + */ + private void blockCopyLinesDown(int srcInternal, int len) { + if (len == 0) return; + int totalRows = mTotalRows; + + int start = len - 1; + // Save away line to be overwritten: + TerminalRow lineToBeOverWritten = mLines[(srcInternal + start + 1) % totalRows]; + // Do the copy from bottom to top. + for (int i = start; i >= 0; --i) + mLines[(srcInternal + i + 1) % totalRows] = mLines[(srcInternal + i) % totalRows]; + // Put back overwritten line, now above the block: + mLines[(srcInternal) % totalRows] = lineToBeOverWritten; + } + + /** + * Scroll the screen down one line. To scroll the whole screen of a 24 line screen, the arguments would be (0, 24). + * + * @param topMargin First line that is scrolled. + * @param bottomMargin One line after the last line that is scrolled. + * @param style the style for the newly exposed line. + */ + public void scrollDownOneLine(int topMargin, int bottomMargin, long style) { + if (topMargin > bottomMargin - 1 || topMargin < 0 || bottomMargin > mScreenRows) + throw new IllegalArgumentException("topMargin=" + topMargin + ", bottomMargin=" + bottomMargin + ", mScreenRows=" + mScreenRows); + + // Copy the fixed topMargin lines one line down so that they remain on screen in same position: + blockCopyLinesDown(mScreenFirstRow, topMargin); + // Copy the fixed mScreenRows-bottomMargin lines one line down so that they remain on screen in same + // position: + blockCopyLinesDown(externalToInternalRow(bottomMargin), mScreenRows - bottomMargin); + + // Update the screen location in the ring buffer: + mScreenFirstRow = (mScreenFirstRow + 1) % mTotalRows; + // Note that the history has grown if not already full: + if (mActiveTranscriptRows < mTotalRows - mScreenRows) mActiveTranscriptRows++; + + // Blank the newly revealed line above the bottom margin: + int blankRow = externalToInternalRow(bottomMargin - 1); + if (mLines[blankRow] == null) { + mLines[blankRow] = new TerminalRow(mColumns, style); + } else { + mLines[blankRow].clear(style); + } + } + + /** + * Block copy characters from one position in the screen to another. The two positions can overlap. All characters + * of the source and destination must be within the bounds of the screen, or else an InvalidParameterException will + * be thrown. + * + * @param sx source X coordinate + * @param sy source Y coordinate + * @param w width + * @param h height + * @param dx destination X coordinate + * @param dy destination Y coordinate + */ + public void blockCopy(int sx, int sy, int w, int h, int dx, int dy) { + if (w == 0) return; + if (sx < 0 || sx + w > mColumns || sy < 0 || sy + h > mScreenRows || dx < 0 || dx + w > mColumns || dy < 0 || dy + h > mScreenRows) + throw new IllegalArgumentException(); + boolean copyingUp = sy > dy; + for (int y = 0; y < h; y++) { + int y2 = copyingUp ? y : (h - (y + 1)); + TerminalRow sourceRow = allocateFullLineIfNecessary(externalToInternalRow(sy + y2)); + allocateFullLineIfNecessary(externalToInternalRow(dy + y2)).copyInterval(sourceRow, sx, sx + w, dx); + } + } + + /** + * Block set characters. All characters must be within the bounds of the screen, or else and + * InvalidParemeterException will be thrown. Typically this is called with a "val" argument of 32 to clear a block + * of characters. + */ + public void blockSet(int sx, int sy, int w, int h, int val, long style) { + if (sx < 0 || sx + w > mColumns || sy < 0 || sy + h > mScreenRows) { + throw new IllegalArgumentException( + "Illegal arguments! blockSet(" + sx + ", " + sy + ", " + w + ", " + h + ", " + val + ", " + mColumns + ", " + mScreenRows + ")"); + } + for (int y = 0; y < h; y++) + for (int x = 0; x < w; x++) + setChar(sx + x, sy + y, val, style); + } + + public TerminalRow allocateFullLineIfNecessary(int row) { + return (mLines[row] == null) ? (mLines[row] = new TerminalRow(mColumns, 0)) : mLines[row]; + } + + public void setChar(int column, int row, int codePoint, long style) { + if (row < 0 || row >= mScreenRows || column < 0 || column >= mColumns) + throw new IllegalArgumentException("TerminalBuffer.setChar(): row=" + row + ", column=" + column + ", mScreenRows=" + mScreenRows + ", mColumns=" + mColumns); + row = externalToInternalRow(row); + allocateFullLineIfNecessary(row).setChar(column, codePoint, style); + } + + public long getStyleAt(int externalRow, int column) { + return allocateFullLineIfNecessary(externalToInternalRow(externalRow)).getStyle(column); + } + + /** Support for http://vt100.net/docs/vt510-rm/DECCARA and http://vt100.net/docs/vt510-rm/DECCARA */ + public void setOrClearEffect(int bits, boolean setOrClear, boolean reverse, boolean rectangular, int leftMargin, int rightMargin, int top, int left, + int bottom, int right) { + for (int y = top; y < bottom; y++) { + TerminalRow line = mLines[externalToInternalRow(y)]; + int startOfLine = (rectangular || y == top) ? left : leftMargin; + int endOfLine = (rectangular || y + 1 == bottom) ? right : rightMargin; + for (int x = startOfLine; x < endOfLine; x++) { + long currentStyle = line.getStyle(x); + int foreColor = TextStyle.decodeForeColor(currentStyle); + int backColor = TextStyle.decodeBackColor(currentStyle); + int effect = TextStyle.decodeEffect(currentStyle); + if (reverse) { + // Clear out the bits to reverse and add them back in reversed: + effect = (effect & ~bits) | (bits & ~effect); + } else if (setOrClear) { + effect |= bits; + } else { + effect &= ~bits; + } + line.mStyle[x] = TextStyle.encode(foreColor, backColor, effect); + } + } + } + + public void clearTranscript() { + if (mScreenFirstRow < mActiveTranscriptRows) { + Arrays.fill(mLines, mTotalRows + mScreenFirstRow - mActiveTranscriptRows, mTotalRows, null); + Arrays.fill(mLines, 0, mScreenFirstRow, null); + } else { + Arrays.fill(mLines, mScreenFirstRow - mActiveTranscriptRows, mScreenFirstRow, null); + } + mActiveTranscriptRows = 0; + } + +} diff --git a/app/src/main/java/com/termux/terminal/TerminalColorScheme.java b/app/src/main/java/com/termux/terminal/TerminalColorScheme.java new file mode 100644 index 0000000..4088781 --- /dev/null +++ b/app/src/main/java/com/termux/terminal/TerminalColorScheme.java @@ -0,0 +1,126 @@ +package com.termux.terminal; + +import java.util.Map; +import java.util.Properties; + +/** + * Color scheme for a terminal with default colors, which may be overridden (and then reset) from the shell using + * Operating System Control (OSC) sequences. + * + * @see TerminalColors + */ +public final class TerminalColorScheme { + + /** http://upload.wikimedia.org/wikipedia/en/1/15/Xterm_256color_chart.svg, but with blue color brighter. */ + private static final int[] DEFAULT_COLORSCHEME = { + // 16 original colors. First 8 are dim. + 0xff000000, // black + 0xffcd0000, // dim red + 0xff00cd00, // dim green + 0xffcdcd00, // dim yellow + 0xff6495ed, // dim blue + 0xffcd00cd, // dim magenta + 0xff00cdcd, // dim cyan + 0xffe5e5e5, // dim white + // Second 8 are bright: + 0xff7f7f7f, // medium grey + 0xffff0000, // bright red + 0xff00ff00, // bright green + 0xffffff00, // bright yellow + 0xff5c5cff, // light blue + 0xffff00ff, // bright magenta + 0xff00ffff, // bright cyan + 0xffffffff, // bright white + + // 216 color cube, six shades of each color: + 0xff000000, 0xff00005f, 0xff000087, 0xff0000af, 0xff0000d7, 0xff0000ff, 0xff005f00, 0xff005f5f, 0xff005f87, 0xff005faf, 0xff005fd7, 0xff005fff, + 0xff008700, 0xff00875f, 0xff008787, 0xff0087af, 0xff0087d7, 0xff0087ff, 0xff00af00, 0xff00af5f, 0xff00af87, 0xff00afaf, 0xff00afd7, 0xff00afff, + 0xff00d700, 0xff00d75f, 0xff00d787, 0xff00d7af, 0xff00d7d7, 0xff00d7ff, 0xff00ff00, 0xff00ff5f, 0xff00ff87, 0xff00ffaf, 0xff00ffd7, 0xff00ffff, + 0xff5f0000, 0xff5f005f, 0xff5f0087, 0xff5f00af, 0xff5f00d7, 0xff5f00ff, 0xff5f5f00, 0xff5f5f5f, 0xff5f5f87, 0xff5f5faf, 0xff5f5fd7, 0xff5f5fff, + 0xff5f8700, 0xff5f875f, 0xff5f8787, 0xff5f87af, 0xff5f87d7, 0xff5f87ff, 0xff5faf00, 0xff5faf5f, 0xff5faf87, 0xff5fafaf, 0xff5fafd7, 0xff5fafff, + 0xff5fd700, 0xff5fd75f, 0xff5fd787, 0xff5fd7af, 0xff5fd7d7, 0xff5fd7ff, 0xff5fff00, 0xff5fff5f, 0xff5fff87, 0xff5fffaf, 0xff5fffd7, 0xff5fffff, + 0xff870000, 0xff87005f, 0xff870087, 0xff8700af, 0xff8700d7, 0xff8700ff, 0xff875f00, 0xff875f5f, 0xff875f87, 0xff875faf, 0xff875fd7, 0xff875fff, + 0xff878700, 0xff87875f, 0xff878787, 0xff8787af, 0xff8787d7, 0xff8787ff, 0xff87af00, 0xff87af5f, 0xff87af87, 0xff87afaf, 0xff87afd7, 0xff87afff, + 0xff87d700, 0xff87d75f, 0xff87d787, 0xff87d7af, 0xff87d7d7, 0xff87d7ff, 0xff87ff00, 0xff87ff5f, 0xff87ff87, 0xff87ffaf, 0xff87ffd7, 0xff87ffff, + 0xffaf0000, 0xffaf005f, 0xffaf0087, 0xffaf00af, 0xffaf00d7, 0xffaf00ff, 0xffaf5f00, 0xffaf5f5f, 0xffaf5f87, 0xffaf5faf, 0xffaf5fd7, 0xffaf5fff, + 0xffaf8700, 0xffaf875f, 0xffaf8787, 0xffaf87af, 0xffaf87d7, 0xffaf87ff, 0xffafaf00, 0xffafaf5f, 0xffafaf87, 0xffafafaf, 0xffafafd7, 0xffafafff, + 0xffafd700, 0xffafd75f, 0xffafd787, 0xffafd7af, 0xffafd7d7, 0xffafd7ff, 0xffafff00, 0xffafff5f, 0xffafff87, 0xffafffaf, 0xffafffd7, 0xffafffff, + 0xffd70000, 0xffd7005f, 0xffd70087, 0xffd700af, 0xffd700d7, 0xffd700ff, 0xffd75f00, 0xffd75f5f, 0xffd75f87, 0xffd75faf, 0xffd75fd7, 0xffd75fff, + 0xffd78700, 0xffd7875f, 0xffd78787, 0xffd787af, 0xffd787d7, 0xffd787ff, 0xffd7af00, 0xffd7af5f, 0xffd7af87, 0xffd7afaf, 0xffd7afd7, 0xffd7afff, + 0xffd7d700, 0xffd7d75f, 0xffd7d787, 0xffd7d7af, 0xffd7d7d7, 0xffd7d7ff, 0xffd7ff00, 0xffd7ff5f, 0xffd7ff87, 0xffd7ffaf, 0xffd7ffd7, 0xffd7ffff, + 0xffff0000, 0xffff005f, 0xffff0087, 0xffff00af, 0xffff00d7, 0xffff00ff, 0xffff5f00, 0xffff5f5f, 0xffff5f87, 0xffff5faf, 0xffff5fd7, 0xffff5fff, + 0xffff8700, 0xffff875f, 0xffff8787, 0xffff87af, 0xffff87d7, 0xffff87ff, 0xffffaf00, 0xffffaf5f, 0xffffaf87, 0xffffafaf, 0xffffafd7, 0xffffafff, + 0xffffd700, 0xffffd75f, 0xffffd787, 0xffffd7af, 0xffffd7d7, 0xffffd7ff, 0xffffff00, 0xffffff5f, 0xffffff87, 0xffffffaf, 0xffffffd7, 0xffffffff, + + // 24 grey scale ramp: + 0xff080808, 0xff121212, 0xff1c1c1c, 0xff262626, 0xff303030, 0xff3a3a3a, 0xff444444, 0xff4e4e4e, 0xff585858, 0xff626262, 0xff6c6c6c, 0xff767676, + 0xff808080, 0xff8a8a8a, 0xff949494, 0xff9e9e9e, 0xffa8a8a8, 0xffb2b2b2, 0xffbcbcbc, 0xffc6c6c6, 0xffd0d0d0, 0xffdadada, 0xffe4e4e4, 0xffeeeeee, + + // COLOR_INDEX_DEFAULT_FOREGROUND, COLOR_INDEX_DEFAULT_BACKGROUND and COLOR_INDEX_DEFAULT_CURSOR: + 0xffffffff, 0xff000000, 0xffffffff}; + + public final int[] mDefaultColors = new int[TextStyle.NUM_INDEXED_COLORS]; + + public TerminalColorScheme() { + reset(); + } + + private void reset() { + System.arraycopy(DEFAULT_COLORSCHEME, 0, mDefaultColors, 0, TextStyle.NUM_INDEXED_COLORS); + } + + public void updateWith(Properties props) { + reset(); + boolean cursorPropExists = false; + for (Map.Entry entries : props.entrySet()) { + String key = (String) entries.getKey(); + String value = (String) entries.getValue(); + int colorIndex; + + if (key.equals("foreground")) { + colorIndex = TextStyle.COLOR_INDEX_FOREGROUND; + } else if (key.equals("background")) { + colorIndex = TextStyle.COLOR_INDEX_BACKGROUND; + } else if (key.equals("cursor")) { + colorIndex = TextStyle.COLOR_INDEX_CURSOR; + cursorPropExists = true; + } else if (key.startsWith("color")) { + try { + colorIndex = Integer.parseInt(key.substring(5)); + } catch (NumberFormatException e) { + throw new IllegalArgumentException("Invalid property: '" + key + "'"); + } + } else { + throw new IllegalArgumentException("Invalid property: '" + key + "'"); + } + + int colorValue = TerminalColors.parse(value); + if (colorValue == 0) + throw new IllegalArgumentException("Property '" + key + "' has invalid color: '" + value + "'"); + + mDefaultColors[colorIndex] = colorValue; + } + + if (!cursorPropExists) + setCursorColorForBackground(); + } + + /** + * If the "cursor" color is not set by user, we need to decide on the appropriate color that will + * be visible on the current terminal background. White will not be visible on light backgrounds + * and black won't be visible on dark backgrounds. So we find the perceived brightness of the + * background color and if its below the threshold (too dark), we use white cursor and if its + * above (too bright), we use black cursor. + */ + public void setCursorColorForBackground() { + int backgroundColor = mDefaultColors[TextStyle.COLOR_INDEX_BACKGROUND]; + int brightness = TerminalColors.getPerceivedBrightnessOfColor(backgroundColor); + if (brightness > 0) { + if (brightness < 130) + mDefaultColors[TextStyle.COLOR_INDEX_CURSOR] = 0xffffffff; + else + mDefaultColors[TextStyle.COLOR_INDEX_CURSOR] = 0xff000000; + } + } + +} diff --git a/app/src/main/java/com/termux/terminal/TerminalColors.java b/app/src/main/java/com/termux/terminal/TerminalColors.java new file mode 100644 index 0000000..25135a2 --- /dev/null +++ b/app/src/main/java/com/termux/terminal/TerminalColors.java @@ -0,0 +1,96 @@ +package com.termux.terminal; + +import android.graphics.Color; + +/** Current terminal colors (if different from default). */ +public final class TerminalColors { + + /** Static data - a bit ugly but ok for now. */ + public static final TerminalColorScheme COLOR_SCHEME = new TerminalColorScheme(); + + /** + * The current terminal colors, which are normally set from the color theme, but may be set dynamically with the OSC + * 4 control sequence. + */ + public final int[] mCurrentColors = new int[TextStyle.NUM_INDEXED_COLORS]; + + /** Create a new instance with default colors from the theme. */ + public TerminalColors() { + reset(); + } + + /** Reset a particular indexed color with the default color from the color theme. */ + public void reset(int index) { + mCurrentColors[index] = COLOR_SCHEME.mDefaultColors[index]; + } + + /** Reset all indexed colors with the default color from the color theme. */ + public void reset() { + System.arraycopy(COLOR_SCHEME.mDefaultColors, 0, mCurrentColors, 0, TextStyle.NUM_INDEXED_COLORS); + } + + /** + * Parse color according to http://manpages.ubuntu.com/manpages/intrepid/man3/XQueryColor.3.html + *

+ * Highest bit is set if successful, so return value is 0xFF${R}${G}${B}. Return 0 if failed. + */ + static int parse(String c) { + try { + int skipInitial, skipBetween; + if (c.charAt(0) == '#') { + // #RGB, #RRGGBB, #RRRGGGBBB or #RRRRGGGGBBBB. Most significant bits. + skipInitial = 1; + skipBetween = 0; + } else if (c.startsWith("rgb:")) { + // rgb:// where , , := h | hh | hhh | hhhh. Scaled. + skipInitial = 4; + skipBetween = 1; + } else { + return 0; + } + int charsForColors = c.length() - skipInitial - 2 * skipBetween; + if (charsForColors % 3 != 0) return 0; // Unequal lengths. + int componentLength = charsForColors / 3; + double mult = 255 / (Math.pow(2, componentLength * 4) - 1); + + int currentPosition = skipInitial; + String rString = c.substring(currentPosition, currentPosition + componentLength); + currentPosition += componentLength + skipBetween; + String gString = c.substring(currentPosition, currentPosition + componentLength); + currentPosition += componentLength + skipBetween; + String bString = c.substring(currentPosition, currentPosition + componentLength); + + int r = (int) (Integer.parseInt(rString, 16) * mult); + int g = (int) (Integer.parseInt(gString, 16) * mult); + int b = (int) (Integer.parseInt(bString, 16) * mult); + return 0xFF << 24 | r << 16 | g << 8 | b; + } catch (NumberFormatException | IndexOutOfBoundsException e) { + return 0; + } + } + + /** Try parse a color from a text parameter and into a specified index. */ + public void tryParseColor(int intoIndex, String textParameter) { + int c = parse(textParameter); + if (c != 0) mCurrentColors[intoIndex] = c; + } + + /** + * Get the perceived brightness of the color based on its RGB components. + * + * https://www.nbdtech.com/Blog/archive/2008/04/27/Calculating-the-Perceived-Brightness-of-a-Color.aspx + * http://alienryderflex.com/hsp.html + * + * @param color The color code int. + * @return Returns value between 0-255. + */ + public static int getPerceivedBrightnessOfColor(int color) { + return (int) + Math.floor(Math.sqrt( + Math.pow(Color.red(color), 2) * 0.241 + + Math.pow(Color.green(color), 2) * 0.691 + + Math.pow(Color.blue(color), 2) * 0.068 + )); + } + +} diff --git a/app/src/main/java/com/termux/terminal/TerminalEmulator.java b/app/src/main/java/com/termux/terminal/TerminalEmulator.java new file mode 100644 index 0000000..045583c --- /dev/null +++ b/app/src/main/java/com/termux/terminal/TerminalEmulator.java @@ -0,0 +1,2706 @@ +package com.termux.terminal; + +import android.util.Base64; + +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Locale; +import java.util.Objects; +import java.util.Stack; + +/** + * 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. + *

+ * References: + *

+ */ +public final class TerminalEmulator { + + /** Log unknown or unimplemented escape sequences received from the shell process. */ + private static final boolean LOG_ESCAPE_SEQUENCES = false; + + public static final int MOUSE_LEFT_BUTTON = 0; + + /** Mouse moving while having left mouse button pressed. */ + public static final int MOUSE_LEFT_BUTTON_MOVED = 32; + public static final int MOUSE_WHEELUP_BUTTON = 64; + public static final int MOUSE_WHEELDOWN_BUTTON = 65; + + /** Used for invalid data - http://en.wikipedia.org/wiki/Replacement_character#Replacement_character */ + public static final int UNICODE_REPLACEMENT_CHAR = 0xFFFD; + + /** 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)} */ + private static final int ESC = 1; + /** Escape processing: Have seen ESC POUND */ + private static final int ESC_POUND = 2; + /** Escape processing: Have seen ESC and a character-set-select ( char */ + private static final int ESC_SELECT_LEFT_PAREN = 3; + /** Escape processing: Have seen ESC and a character-set-select ) char */ + private static final int ESC_SELECT_RIGHT_PAREN = 4; + /** Escape processing: "ESC [" or CSI (Control Sequence Introducer). */ + private static final int ESC_CSI = 6; + /** Escape processing: ESC [ ? */ + private static final int ESC_CSI_QUESTIONMARK = 7; + /** Escape processing: ESC [ $ */ + 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) */ + 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 [ > */ + 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 processing: CSI > */ + private static final int ESC_CSI_QUESTIONMARK_ARG_DOLLAR = 14; + /** Escape processing: CSI $ARGS ' ' */ + private static final int ESC_CSI_ARGS_SPACE = 15; + /** Escape processing: CSI $ARGS '*' */ + private static final int ESC_CSI_ARGS_ASTERIX = 16; + /** Escape processing: CSI " */ + private static final int ESC_CSI_DOUBLE_QUOTE = 17; + /** Escape processing: CSI ' */ + 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). */ + 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 [ */ + private static final int ESC_CSI_UNSUPPORTED_PARAMETER_BYTE = 22; + /** Escape processing: ESC [ */ + private static final int ESC_CSI_UNSUPPORTED_INTERMEDIATE_BYTE = 23; + /** Escape processing: "ESC [ <" (kitty keyboard protocol pop). */ + private static final int ESC_CSI_LESSTHAN = 24; + /** Escape processing: "ESC [ =" (kitty keyboard protocol set). */ + private static final int ESC_CSI_EQUALS = 25; + + /** 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; + /** + * http://www.vt100.net/docs/vt510-rm/DECOM: "When DECOM is set, the home cursor position is at the upper-left + * corner of the screen, within the margins. The starting point for line numbers depends on the current top margin + * setting. The cursor cannot move outside of the margins. When DECOM is reset, the home cursor position is at the + * upper-left corner of the screen. The starting point for line numbers is independent of the margins. The cursor + * can move outside of the margins." + */ + private static final int DECSET_BIT_ORIGIN_MODE = 1 << 2; + /** + * http://www.vt100.net/docs/vt510-rm/DECAWM: "If the DECAWM function is set, then graphic characters received when + * the cursor is at the right border of the page appear at the beginning of the next line. Any text on the page + * scrolls up if the cursor is at the end of the scrolling region. If the DECAWM function is reset, then graphic + * characters received when the cursor is at the right border of the page replace characters already on the page." + */ + private static final int DECSET_BIT_AUTOWRAP = 1 << 3; + /** DECSET 25 - if the cursor should be enabled, {@link #isCursorEnabled()}. */ + private static final int DECSET_BIT_CURSOR_ENABLED = 1 << 4; + private static final int DECSET_BIT_APPLICATION_KEYPAD = 1 << 5; + /** DECSET 1000 - if to report mouse press&release events. */ + private static final int DECSET_BIT_MOUSE_TRACKING_PRESS_RELEASE = 1 << 6; + /** DECSET 1002 - like 1000, but report moving mouse while pressed. */ + private static final int DECSET_BIT_MOUSE_TRACKING_BUTTON_EVENT = 1 << 7; + /** DECSET 1004 - NOT implemented. */ + private static final int DECSET_BIT_SEND_FOCUS_EVENTS = 1 << 8; + /** DECSET 1006 - SGR-like mouse protocol (the modern sane choice). */ + private static final int DECSET_BIT_MOUSE_PROTOCOL_SGR = 1 << 9; + /** DECSET 2004 - see {@link #paste(String)} */ + private static final int DECSET_BIT_BRACKETED_PASTE_MODE = 1 << 10; + /** Toggled with DECLRMM - http://www.vt100.net/docs/vt510-rm/DECLRMM */ + private static final int DECSET_BIT_LEFTRIGHT_MARGIN_MODE = 1 << 11; + /** Not really DECSET bit... - http://www.vt100.net/docs/vt510-rm/DECSACE */ + private static final int DECSET_BIT_RECTANGULAR_CHANGEATTRIBUTE = 1 << 12; + /** DECSET 1003 - like 1002, but also report motion without any button pressed. */ + private static final int DECSET_BIT_MOUSE_TRACKING_ANY_EVENT = 1 << 13; + private static final int MOUSE_TRACKING_BITS = DECSET_BIT_MOUSE_TRACKING_PRESS_RELEASE + | DECSET_BIT_MOUSE_TRACKING_BUTTON_EVENT | DECSET_BIT_MOUSE_TRACKING_ANY_EVENT; + + /** Kitty keyboard protocol flags supported here: 1 = disambiguate escape codes. */ + public static final int KITTY_KEYBOARD_SUPPORTED_FLAGS = 1; + private static final int KITTY_KEYBOARD_STACK_MAX = 16; + /** Kitty keyboard protocol flag stacks; the main and alternate screens have separate ones. */ + private final int[] mKittyStackMain = new int[KITTY_KEYBOARD_STACK_MAX]; + private final int[] mKittyStackAlt = new int[KITTY_KEYBOARD_STACK_MAX]; + private int mKittyDepthMain, mKittyDepthAlt; + + /** xterm modifyOtherKeys level set by "CSI > 4 ; level m" (0, 1 or 2). */ + private int mModifyOtherKeys; + + + private String mTitle; + private final Stack mTitleStack = new Stack<>(); + + /** The cursor position. Between (0,0) and (mRows-1, mColumns-1). */ + private int mCursorRow, mCursorCol; + + /** The number of character rows and columns in the terminal screen. */ + public int mRows, mColumns; + + /** Size of a terminal cell in pixels. */ + private int mCellWidthPixels, mCellHeightPixels; + + /** The number of terminal transcript rows that can be scrolled back to. */ + public static final int TERMINAL_TRANSCRIPT_ROWS_MIN = 100; + public static final int TERMINAL_TRANSCRIPT_ROWS_MAX = 50000; + public static final int DEFAULT_TERMINAL_TRANSCRIPT_ROWS = 2000; + + + /* The supported terminal cursor styles. */ + + public static final int TERMINAL_CURSOR_STYLE_BLOCK = 0; + public static final int TERMINAL_CURSOR_STYLE_UNDERLINE = 1; + public static final int TERMINAL_CURSOR_STYLE_BAR = 2; + public static final int DEFAULT_TERMINAL_CURSOR_STYLE = TERMINAL_CURSOR_STYLE_BLOCK; + public static final Integer[] TERMINAL_CURSOR_STYLES_LIST = new Integer[]{TERMINAL_CURSOR_STYLE_BLOCK, TERMINAL_CURSOR_STYLE_UNDERLINE, TERMINAL_CURSOR_STYLE_BAR}; + + /** The terminal cursor styles. */ + private int mCursorStyle = DEFAULT_TERMINAL_CURSOR_STYLE; + + + /** The normal screen buffer. Stores the characters that appear on the screen of the emulated terminal. */ + private final TerminalBuffer mMainBuffer; + /** + * The alternate screen buffer, exactly as large as the display and contains no additional saved lines (so that when + * the alternate screen buffer is active, you cannot scroll back to view saved lines). + *

+ * See http://www.xfree86.org/current/ctlseqs.html#The%20Alternate%20Screen%20Buffer + */ + final TerminalBuffer mAltBuffer; + /** The current screen buffer, pointing at either {@link #mMainBuffer} or {@link #mAltBuffer}. */ + private TerminalBuffer mScreen; + + /** The terminal session this emulator is bound to. */ + private final TerminalOutput mSession; + + TerminalSessionClient mClient; + + /** Keeps track of the current argument of the current escape sequence. Ranges from 0 to MAX_ESCAPE_PARAMETERS-1. */ + private int mArgIndex; + /** Holds the arguments of the current escape sequence. */ + private final int[] mArgs = new int[MAX_ESCAPE_PARAMETERS]; + /** 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(); + + /** + * True if the current escape sequence should continue, false if the current escape sequence should be terminated. + * Used when parsing a single character. + */ + private boolean mContinueSequence; + + /** The current state of the escape sequence state machine. One of the ESC_* constants. */ + private int mEscapeState; + + private final SavedScreenState mSavedStateMain = new SavedScreenState(); + private final SavedScreenState mSavedStateAlt = new SavedScreenState(); + + /** http://www.vt100.net/docs/vt102-ug/table5-15.html */ + private boolean mUseLineDrawingG0, mUseLineDrawingG1, mUseLineDrawingUsesG0 = true; + + /** + * @see TerminalEmulator#mapDecSetBitToInternalBit(int) + */ + private int mCurrentDecSetFlags, mSavedDecSetFlags; + + /** + * If insert mode (as opposed to replace mode) is active. In insert mode new characters are inserted, pushing + * existing text to the right. Characters moved past the right margin are lost. + */ + private boolean mInsertMode; + + /** An array of tab stops. mTabStop[i] is true if there is a tab stop set for column i. */ + private boolean[] mTabStop; + + /** + * Top margin of screen for scrolling ranges from 0 to mRows-2. Bottom margin ranges from mTopMargin + 2 to mRows + * (Defines the first row after the scrolling region). Left/right margin in [0, mColumns]. + */ + private int mTopMargin, mBottomMargin, mLeftMargin, mRightMargin; + + /** + * If the next character to be emitted will be automatically wrapped to the next line. Used to disambiguate the case + * where the cursor is positioned on the last column (mColumns-1). When standing there, a written character will be + * output in the last column, the cursor not moving but this flag will be set. When outputting another character + * this will move to the next line. + */ + private boolean mAboutToAutoWrap; + + /** + * If the cursor blinking is enabled. It requires cursor itself to be enabled, which is controlled + * byt whether {@link #DECSET_BIT_CURSOR_ENABLED} bit is set or not. + */ + private boolean mCursorBlinkingEnabled; + + /** + * If currently cursor should be in a visible state or not if {@link #mCursorBlinkingEnabled} + * is {@code true}. + */ + private boolean mCursorBlinkState; + + /** + * Current foreground, background and underline colors. Can either be a color index in [0,259] or a truecolor (24-bit) value. + * For a 24-bit value the top byte (0xff000000) is set. + * + *

Note that the underline color is currently parsed but not yet used during rendering. + * + * @see TextStyle + */ + int mForeColor, mBackColor, mUnderlineColor; + + /** Current {@link TextStyle} effect. */ + int mEffect; + + /** + * The number of scrolled lines since last calling {@link #clearScrollCounter()}. Used for moving selection up along + * with the scrolling text. + */ + private int mScrollCounter = 0; + + private byte mUtf8ToFollow, mUtf8Index; + private final byte[] mUtf8InputBuffer = new byte[4]; + private int mLastEmittedCodePoint = -1; + + public final TerminalColors mColors = new TerminalColors(); + + private static final String LOG_TAG = "TerminalEmulator"; + + private boolean isDecsetInternalBitSet(int bit) { + return (mCurrentDecSetFlags & bit) != 0; + } + + private void setDecsetinternalBit(int internalBit, boolean set) { + if (set) { + // The mouse modes are mutually exclusive. + if ((internalBit & MOUSE_TRACKING_BITS) != 0) mCurrentDecSetFlags &= ~MOUSE_TRACKING_BITS; + } + if (set) { + mCurrentDecSetFlags |= internalBit; + } else { + mCurrentDecSetFlags &= ~internalBit; + } + } + + static int mapDecSetBitToInternalBit(int decsetBit) { + switch (decsetBit) { + case 1: + return DECSET_BIT_APPLICATION_CURSOR_KEYS; + case 5: + return DECSET_BIT_REVERSE_VIDEO; + case 6: + return DECSET_BIT_ORIGIN_MODE; + case 7: + return DECSET_BIT_AUTOWRAP; + case 25: + return DECSET_BIT_CURSOR_ENABLED; + case 66: + return DECSET_BIT_APPLICATION_KEYPAD; + case 69: + return DECSET_BIT_LEFTRIGHT_MARGIN_MODE; + case 1000: + return DECSET_BIT_MOUSE_TRACKING_PRESS_RELEASE; + case 1002: + return DECSET_BIT_MOUSE_TRACKING_BUTTON_EVENT; + case 1003: + return DECSET_BIT_MOUSE_TRACKING_ANY_EVENT; + case 1004: + return DECSET_BIT_SEND_FOCUS_EVENTS; + case 1006: + return DECSET_BIT_MOUSE_PROTOCOL_SGR; + case 2004: + return DECSET_BIT_BRACKETED_PASTE_MODE; + default: + return -1; + // throw new IllegalArgumentException("Unsupported decset: " + 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); + mClient = client; + mRows = rows; + mColumns = columns; + mCellWidthPixels = cellWidthPixels; + mCellHeightPixels = cellHeightPixels; + mTabStop = new boolean[mColumns]; + reset(); + } + + public void updateTerminalSessionClient(TerminalSessionClient client) { + mClient = client; + setCursorStyle(); + setCursorBlinkState(true); + } + + public TerminalBuffer getScreen() { + return mScreen; + } + + public boolean isAlternateBufferActive() { + return mScreen == mAltBuffer; + } + + private int getTerminalTranscriptRows(Integer transcriptRows) { + if (transcriptRows == null || transcriptRows < TERMINAL_TRANSCRIPT_ROWS_MIN || transcriptRows > TERMINAL_TRANSCRIPT_ROWS_MAX) + return DEFAULT_TERMINAL_TRANSCRIPT_ROWS; + else + return transcriptRows; + } + + /** + * @param mouseButton one of the MOUSE_* constants of this class. + */ + public void sendMouseEvent(int mouseButton, int column, int row, boolean pressed) { + if (column < 1) column = 1; + if (column > mColumns) column = mColumns; + if (row < 1) row = 1; + if (row > mRows) row = mRows; + + boolean motion = (mouseButton & MOUSE_LEFT_BUTTON_MOVED) != 0 && mouseButton < MOUSE_WHEELUP_BUTTON; + boolean hover = motion && (mouseButton & 3) == 3; + if (motion && !isDecsetInternalBitSet(DECSET_BIT_MOUSE_TRACKING_ANY_EVENT) + && (hover || !isDecsetInternalBitSet(DECSET_BIT_MOUSE_TRACKING_BUTTON_EVENT))) { + // Do not send tracking. + } else if (isDecsetInternalBitSet(DECSET_BIT_MOUSE_PROTOCOL_SGR)) { + mSession.write(String.format("\033[<%d;%d;%d" + (pressed ? 'M' : 'm'), mouseButton, column, row)); + } else { + mouseButton = pressed ? mouseButton : 3; // 3 for release of all buttons. + // Clip to screen, and clip to the limits of 8-bit data. + boolean out_of_bounds = column > 255 - 32 || row > 255 - 32; + if (!out_of_bounds) { + byte[] data = {'\033', '[', 'M', (byte) (32 + mouseButton), (byte) (32 + column), (byte) (32 + row)}; + mSession.write(data, 0, data.length); + } + } + } + + public void resize(int columns, int rows, int cellWidthPixels, int cellHeightPixels) { + this.mCellWidthPixels = cellWidthPixels; + this.mCellHeightPixels = cellHeightPixels; + + if (mRows == rows && mColumns == columns) { + return; + } else if (columns < 2 || rows < 2) { + throw new IllegalArgumentException("rows=" + rows + ", columns=" + columns); + } + + if (mRows != rows) { + mRows = rows; + mTopMargin = 0; + mBottomMargin = mRows; + } + if (mColumns != columns) { + int oldColumns = mColumns; + mColumns = columns; + boolean[] oldTabStop = mTabStop; + mTabStop = new boolean[mColumns]; + setDefaultTabStops(); + int toTransfer = Math.min(oldColumns, columns); + System.arraycopy(oldTabStop, 0, mTabStop, 0, toTransfer); + mLeftMargin = 0; + mRightMargin = mColumns; + } + + resizeScreen(); + } + + private void resizeScreen() { + final int[] cursor = {mCursorCol, mCursorRow}; + int newTotalRows = (mScreen == mAltBuffer) ? mRows : mMainBuffer.mTotalRows; + mScreen.resize(mColumns, mRows, newTotalRows, cursor, getStyle(), isAlternateBufferActive()); + mCursorCol = cursor[0]; + mCursorRow = cursor[1]; + } + + public int getCursorRow() { + return mCursorRow; + } + + public int getCursorCol() { + return mCursorCol; + } + + /** Get the terminal cursor style. It will be one of {@link #TERMINAL_CURSOR_STYLES_LIST} */ + public int getCursorStyle() { + return mCursorStyle; + } + + /** Set the terminal cursor style. */ + public void setCursorStyle() { + Integer cursorStyle = null; + + if (mClient != null) + cursorStyle = mClient.getTerminalCursorStyle(); + + if (cursorStyle == null || !Arrays.asList(TERMINAL_CURSOR_STYLES_LIST).contains(cursorStyle)) + mCursorStyle = DEFAULT_TERMINAL_CURSOR_STYLE; + else + mCursorStyle = cursorStyle; + } + + public boolean isReverseVideo() { + return isDecsetInternalBitSet(DECSET_BIT_REVERSE_VIDEO); + } + + + + public boolean isCursorEnabled() { + return isDecsetInternalBitSet(DECSET_BIT_CURSOR_ENABLED); + } + public boolean shouldCursorBeVisible() { + if (!isCursorEnabled()) + return false; + else + return mCursorBlinkingEnabled ? mCursorBlinkState : true; + } + + public void setCursorBlinkingEnabled(boolean cursorBlinkingEnabled) { + this.mCursorBlinkingEnabled = cursorBlinkingEnabled; + } + + public void setCursorBlinkState(boolean cursorBlinkState) { + this.mCursorBlinkState = cursorBlinkState; + } + + + + public boolean isKeypadApplicationMode() { + return isDecsetInternalBitSet(DECSET_BIT_APPLICATION_KEYPAD); + } + + public boolean isCursorKeysApplicationMode() { + return isDecsetInternalBitSet(DECSET_BIT_APPLICATION_CURSOR_KEYS); + } + + /** If mouse events are being sent as escape codes to the terminal. */ + public boolean isMouseTrackingActive() { + return (mCurrentDecSetFlags & MOUSE_TRACKING_BITS) != 0; + } + + /** If the application wants motion reports without a button held (DECSET 1003). */ + public boolean isMouseHoverTrackingActive() { + return isDecsetInternalBitSet(DECSET_BIT_MOUSE_TRACKING_ANY_EVENT); + } + + /** If focus in/out should be reported as CSI I / CSI O (DECSET 1004). */ + public boolean isFocusReportingActive() { + return isDecsetInternalBitSet(DECSET_BIT_SEND_FOCUS_EVENTS); + } + + /** Kitty keyboard protocol flags currently in effect for the active screen. */ + public int getKittyKeyboardFlags() { + boolean alt = mScreen == mAltBuffer; + int depth = alt ? mKittyDepthAlt : mKittyDepthMain; + return depth == 0 ? 0 : (alt ? mKittyStackAlt : mKittyStackMain)[depth - 1]; + } + + public int getModifyOtherKeys() { + return mModifyOtherKeys; + } + + private void pushKittyFlags(int flags) { + flags &= KITTY_KEYBOARD_SUPPORTED_FLAGS; + boolean alt = mScreen == mAltBuffer; + int[] stack = alt ? mKittyStackAlt : mKittyStackMain; + int depth = alt ? mKittyDepthAlt : mKittyDepthMain; + if (depth == stack.length) { + System.arraycopy(stack, 1, stack, 0, stack.length - 1); + depth--; + } + stack[depth++] = flags; + if (alt) mKittyDepthAlt = depth; + else mKittyDepthMain = depth; + } + + private void popKittyFlags(int count) { + if (mScreen == mAltBuffer) mKittyDepthAlt = Math.max(0, mKittyDepthAlt - count); + else mKittyDepthMain = Math.max(0, mKittyDepthMain - count); + } + + /** "CSI = flags ; mode u": 1 replaces, 2 sets bits, 3 clears bits of the current entry. */ + private void setKittyFlags(int flags, int mode) { + flags &= KITTY_KEYBOARD_SUPPORTED_FLAGS; + boolean alt = mScreen == mAltBuffer; + int[] stack = alt ? mKittyStackAlt : mKittyStackMain; + int depth = alt ? mKittyDepthAlt : mKittyDepthMain; + if (depth == 0) { + pushKittyFlags(0); + depth = 1; + } + int cur = stack[depth - 1]; + if (mode == 2) cur |= flags; + else if (mode == 3) cur &= ~flags; + else cur = flags; + stack[depth - 1] = cur; + } + + private void setDefaultTabStops() { + for (int i = 0; i < mColumns; i++) + mTabStop[i] = (i & 7) == 0 && i != 0; + } + + /** + * Accept bytes (typically from the pseudo-teletype) and process them. + * + * @param buffer a byte array containing the bytes to be processed + * @param length the number of bytes in the array to process + */ + public void append(byte[] buffer, int length) { + for (int i = 0; i < length; i++) + processByte(buffer[i]); + } + + private void processByte(byte byteToProcess) { + if (mUtf8ToFollow > 0) { + if ((byteToProcess & 0b11000000) == 0b10000000) { + // 10xxxxxx, a continuation byte. + mUtf8InputBuffer[mUtf8Index++] = byteToProcess; + if (--mUtf8ToFollow == 0) { + byte firstByteMask = (byte) (mUtf8Index == 2 ? 0b00011111 : (mUtf8Index == 3 ? 0b00001111 : 0b00000111)); + int codePoint = (mUtf8InputBuffer[0] & firstByteMask); + for (int i = 1; i < mUtf8Index; i++) + codePoint = ((codePoint << 6) | (mUtf8InputBuffer[i] & 0b00111111)); + if (((codePoint <= 0b1111111) && mUtf8Index > 1) || (codePoint < 0b11111111111 && mUtf8Index > 2) + || (codePoint < 0b1111111111111111 && mUtf8Index > 3)) { + // Overlong encoding. + codePoint = UNICODE_REPLACEMENT_CHAR; + } + + mUtf8Index = mUtf8ToFollow = 0; + + if (codePoint >= 0x80 && codePoint <= 0x9F) { + // Sequence decoded to a C1 control character which we ignore. They are + // not used nowadays and increases the risk of messing up the terminal state + // on binary input. XTerm does not allow them in utf-8: + // "It is not possible to use a C1 control obtained from decoding the + // UTF-8 text" - http://invisible-island.net/xterm/ctlseqs/ctlseqs.html + } else { + switch (Character.getType(codePoint)) { + case Character.UNASSIGNED: + case Character.SURROGATE: + codePoint = UNICODE_REPLACEMENT_CHAR; + } + processCodePoint(codePoint); + } + } + } else { + // Not a UTF-8 continuation byte so replace the entire sequence up to now with the replacement char: + mUtf8Index = mUtf8ToFollow = 0; + emitCodePoint(UNICODE_REPLACEMENT_CHAR); + // The Unicode Standard Version 6.2 – Core Specification + // (http://www.unicode.org/versions/Unicode6.2.0/ch03.pdf): + // "If the converter encounters an ill-formed UTF-8 code unit sequence which starts with a valid first + // byte, but which does not continue with valid successor bytes (see Table 3-7), it must not consume the + // successor bytes as part of the ill-formed subsequence + // whenever those successor bytes themselves constitute part of a well-formed UTF-8 code unit + // subsequence." + processByte(byteToProcess); + } + } else { + if ((byteToProcess & 0b10000000) == 0) { // The leading bit is not set so it is a 7-bit ASCII character. + processCodePoint(byteToProcess); + return; + } else if ((byteToProcess & 0b11100000) == 0b11000000) { // 110xxxxx, a two-byte sequence. + mUtf8ToFollow = 1; + } else if ((byteToProcess & 0b11110000) == 0b11100000) { // 1110xxxx, a three-byte sequence. + mUtf8ToFollow = 2; + } else if ((byteToProcess & 0b11111000) == 0b11110000) { // 11110xxx, a four-byte sequence. + mUtf8ToFollow = 3; + } else { + // Not a valid UTF-8 sequence start, signal invalid data: + processCodePoint(UNICODE_REPLACEMENT_CHAR); + return; + } + mUtf8InputBuffer[mUtf8Index++] = byteToProcess; + } + } + + public void processCodePoint(int b) { + // The Application Program-Control (APC) string might be arbitrary non-printable characters, so handle that early. + if (mEscapeState == ESC_APC) { + doApc(b); + return; + } else if (mEscapeState == ESC_APC_ESCAPE) { + doApcEscape(b); + return; + } + + switch (b) { + case 0: // Null character (NUL, ^@). Do nothing. + 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); + else + mSession.onBell(); + break; + case 8: // Backspace (BS, ^H). + if (mLeftMargin == mCursorCol) { + // Jump to previous line if it was auto-wrapped. + int previousRow = mCursorRow - 1; + if (previousRow >= 0 && mScreen.getLineWrap(previousRow)) { + mScreen.clearLineWrap(previousRow); + setCursorRowCol(previousRow, mRightMargin - 1); + } + } else { + setCursorCol(mCursorCol - 1); + } + break; + case 9: // Horizontal tab (HT, \t) - move to next tab stop, but not past edge of screen + // XXX: Should perhaps use color if writing to new cells. Try with + // printf "\033[41m\tXX\033[0m\n" + // The OSX Terminal.app colors the spaces from the tab red, but xterm does not. + // Note that Terminal.app only colors on new cells, in e.g. + // printf "\033[41m\t\r\033[42m\tXX\033[0m\n" + // the first cells are created with a red background, but when tabbing over + // them again with a green background they are not overwritten. + mCursorCol = nextTabStop(1); + break; + case 10: // Line feed (LF, \n). + case 11: // Vertical tab (VT, \v). + case 12: // Form feed (FF, \f). + doLinefeed(); + break; + case 13: // Carriage return (CR, \r). + setCursorCol(mLeftMargin); + break; + case 14: // Shift Out (Ctrl-N, SO) → Switch to Alternate Character Set. This invokes the G1 character set. + mUseLineDrawingUsesG0 = false; + break; + case 15: // Shift In (Ctrl-O, SI) → Switch to Standard Character Set. This invokes the G0 character set. + mUseLineDrawingUsesG0 = true; + break; + case 24: // CAN. + case 26: // SUB. + if (mEscapeState != ESC_NONE) { + // FIXME: What is this?? + mEscapeState = ESC_NONE; + emitCodePoint(127); + } + break; + case 27: // ESC + // Starts an escape sequence unless we're parsing a string + if (mEscapeState == ESC_P) { + // XXX: Ignore escape when reading device control sequence, since it may be part of string terminator. + return; + } else if (mEscapeState != ESC_OSC) { + startEscapeSequence(); + } else { + doOsc(b); + } + break; + default: + mContinueSequence = false; + switch (mEscapeState) { + case ESC_NONE: + if (b >= 32) emitCodePoint(b); + break; + case ESC: + doEsc(b); + break; + case ESC_POUND: + doEscPound(b); + break; + case ESC_SELECT_LEFT_PAREN: // Designate G0 Character Set (ISO 2022, VT100). + mUseLineDrawingG0 = (b == '0'); + break; + case ESC_SELECT_RIGHT_PAREN: // Designate G1 Character Set (ISO 2022, VT100). + mUseLineDrawingG1 = (b == '0'); + break; + case ESC_CSI: + doCsi(b); + break; + case ESC_CSI_UNSUPPORTED_PARAMETER_BYTE: + case ESC_CSI_UNSUPPORTED_INTERMEDIATE_BYTE: + doCsiUnsupportedParameterOrIntermediateByte(b); + break; + case ESC_CSI_EXCLAMATION: + if (b == 'p') { // Soft terminal reset (DECSTR, http://vt100.net/docs/vt510-rm/DECSTR). + reset(); + } else { + unknownSequence(b); + } + break; + case ESC_CSI_QUESTIONMARK: + doCsiQuestionMark(b); + break; + case ESC_CSI_BIGGERTHAN: + doCsiBiggerThan(b); + break; + case ESC_CSI_LESSTHAN: + if (b == 'u') popKittyFlags(getArg0(1)); + else if (b >= 0x40 && b <= 0x7E) finishSequence(); + else parseArg(b); + break; + case ESC_CSI_EQUALS: + if (b == 'u') setKittyFlags(Math.max(0, getArg(0, 0, false)), getArg1(1)); + else if (b >= 0x40 && b <= 0x7E) finishSequence(); + else parseArg(b); + break; + case ESC_CSI_DOLLAR: + boolean originMode = isDecsetInternalBitSet(DECSET_BIT_ORIGIN_MODE); + int effectiveTopMargin = originMode ? mTopMargin : 0; + int effectiveBottomMargin = originMode ? mBottomMargin : mRows; + int effectiveLeftMargin = originMode ? mLeftMargin : 0; + int effectiveRightMargin = originMode ? mRightMargin : mColumns; + switch (b) { + case 'v': // ${CSI}${SRC_TOP}${SRC_LEFT}${SRC_BOTTOM}${SRC_RIGHT}${SRC_PAGE}${DST_TOP}${DST_LEFT}${DST_PAGE}$v" + // Copy rectangular area (DECCRA - http://vt100.net/docs/vt510-rm/DECCRA): + // "If Pbs is greater than Pts, or Pls is greater than Prs, the terminal ignores DECCRA. + // The coordinates of the rectangular area are affected by the setting of origin mode (DECOM). + // DECCRA is not affected by the page margins. + // The copied text takes on the line attributes of the destination area. + // If the value of Pt, Pl, Pb, or Pr exceeds the width or height of the active page, then the value + // is treated as the width or height of that page. + // If the destination area is partially off the page, then DECCRA clips the off-page data. + // DECCRA does not change the active cursor position." + int topSource = Math.min(getArg(0, 1, true) - 1 + effectiveTopMargin, mRows); + int leftSource = Math.min(getArg(1, 1, true) - 1 + effectiveLeftMargin, mColumns); + // Inclusive, so do not subtract one: + int bottomSource = Math.min(Math.max(getArg(2, mRows, true) + effectiveTopMargin, topSource), mRows); + int rightSource = Math.min(Math.max(getArg(3, mColumns, true) + effectiveLeftMargin, leftSource), mColumns); + // int sourcePage = getArg(4, 1, true); + int destionationTop = Math.min(getArg(5, 1, true) - 1 + effectiveTopMargin, mRows); + int destinationLeft = Math.min(getArg(6, 1, true) - 1 + effectiveLeftMargin, mColumns); + // int destinationPage = getArg(7, 1, true); + int heightToCopy = Math.min(mRows - destionationTop, bottomSource - topSource); + int widthToCopy = Math.min(mColumns - destinationLeft, rightSource - leftSource); + mScreen.blockCopy(leftSource, topSource, widthToCopy, heightToCopy, destinationLeft, destionationTop); + break; + case '{': // ${CSI}${TOP}${LEFT}${BOTTOM}${RIGHT}${" + // Selective erase rectangular area (DECSERA - http://www.vt100.net/docs/vt510-rm/DECSERA). + case 'x': // ${CSI}${CHAR};${TOP}${LEFT}${BOTTOM}${RIGHT}$x" + // Fill rectangular area (DECFRA - http://www.vt100.net/docs/vt510-rm/DECFRA). + case 'z': // ${CSI}$${TOP}${LEFT}${BOTTOM}${RIGHT}$z" + // Erase rectangular area (DECERA - http://www.vt100.net/docs/vt510-rm/DECERA). + boolean erase = b != 'x'; + boolean selective = b == '{'; + // Only DECSERA keeps visual attributes, DECERA does not: + boolean keepVisualAttributes = erase && selective; + int argIndex = 0; + int fillChar = erase ? ' ' : getArg(argIndex++, -1, true); + // "Pch can be any value from 32 to 126 or from 160 to 255. If Pch is not in this range, then the + // terminal ignores the DECFRA command": + if ((fillChar >= 32 && fillChar <= 126) || (fillChar >= 160 && fillChar <= 255)) { + // "If the value of Pt, Pl, Pb, or Pr exceeds the width or height of the active page, the value + // is treated as the width or height of that page." + int top = Math.min(getArg(argIndex++, 1, true) + effectiveTopMargin, effectiveBottomMargin + 1); + int left = Math.min(getArg(argIndex++, 1, true) + effectiveLeftMargin, effectiveRightMargin + 1); + int bottom = Math.min(getArg(argIndex++, mRows, true) + effectiveTopMargin, effectiveBottomMargin); + int right = Math.min(getArg(argIndex, mColumns, true) + effectiveLeftMargin, effectiveRightMargin); + long style = getStyle(); + for (int row = top - 1; row < bottom; row++) + for (int col = left - 1; col < right; col++) + if (!selective || (TextStyle.decodeEffect(mScreen.getStyleAt(row, col)) & TextStyle.CHARACTER_ATTRIBUTE_PROTECTED) == 0) + mScreen.setChar(col, row, fillChar, keepVisualAttributes ? mScreen.getStyleAt(row, col) : style); + } + break; + case 'r': // "${CSI}${TOP}${LEFT}${BOTTOM}${RIGHT}${ATTRIBUTES}$r" + // Change attributes in rectangular area (DECCARA - http://vt100.net/docs/vt510-rm/DECCARA). + case 't': // "${CSI}${TOP}${LEFT}${BOTTOM}${RIGHT}${ATTRIBUTES}$t" + // Reverse attributes in rectangular area (DECRARA - http://www.vt100.net/docs/vt510-rm/DECRARA). + boolean reverse = b == 't'; + // FIXME: "coordinates of the rectangular area are affected by the setting of origin mode (DECOM)". + int top = Math.min(getArg(0, 1, true) - 1, effectiveBottomMargin) + effectiveTopMargin; + int left = Math.min(getArg(1, 1, true) - 1, effectiveRightMargin) + effectiveLeftMargin; + int bottom = Math.min(getArg(2, mRows, true) + 1, effectiveBottomMargin - 1) + effectiveTopMargin; + int right = Math.min(getArg(3, mColumns, true) + 1, effectiveRightMargin - 1) + effectiveLeftMargin; + if (mArgIndex >= 4) { + if (mArgIndex >= mArgs.length) mArgIndex = mArgs.length - 1; + for (int i = 4; i <= mArgIndex; i++) { + int bits = 0; + boolean setOrClear = true; // True if setting, false if clearing. + switch (getArg(i, 0, false)) { + case 0: // Attributes off (no bold, no underline, no blink, positive image). + bits = (TextStyle.CHARACTER_ATTRIBUTE_BOLD | TextStyle.CHARACTER_ATTRIBUTE_UNDERLINE | TextStyle.CHARACTER_ATTRIBUTE_BLINK + | TextStyle.CHARACTER_ATTRIBUTE_INVERSE); + if (!reverse) setOrClear = false; + break; + case 1: // Bold. + bits = TextStyle.CHARACTER_ATTRIBUTE_BOLD; + break; + case 4: // Underline. + bits = TextStyle.CHARACTER_ATTRIBUTE_UNDERLINE; + break; + case 5: // Blink. + bits = TextStyle.CHARACTER_ATTRIBUTE_BLINK; + break; + case 7: // Negative image. + bits = TextStyle.CHARACTER_ATTRIBUTE_INVERSE; + break; + case 22: // No bold. + bits = TextStyle.CHARACTER_ATTRIBUTE_BOLD; + setOrClear = false; + break; + case 24: // No underline. + bits = TextStyle.CHARACTER_ATTRIBUTE_UNDERLINE; + setOrClear = false; + break; + case 25: // No blink. + bits = TextStyle.CHARACTER_ATTRIBUTE_BLINK; + setOrClear = false; + break; + case 27: // Positive image. + bits = TextStyle.CHARACTER_ATTRIBUTE_INVERSE; + setOrClear = false; + break; + } + if (reverse && !setOrClear) { + // Reverse attributes in rectangular area ignores non-(1,4,5,7) bits. + } else { + mScreen.setOrClearEffect(bits, setOrClear, reverse, isDecsetInternalBitSet(DECSET_BIT_RECTANGULAR_CHANGEATTRIBUTE), + effectiveLeftMargin, effectiveRightMargin, top, left, bottom, right); + } + } + } else { + // Do nothing. + } + break; + default: + unknownSequence(b); + } + break; + case ESC_CSI_DOUBLE_QUOTE: + if (b == 'q') { + // http://www.vt100.net/docs/vt510-rm/DECSCA + int arg = getArg0(0); + if (arg == 0 || arg == 2) { + // DECSED and DECSEL can erase characters. + mEffect &= ~TextStyle.CHARACTER_ATTRIBUTE_PROTECTED; + } else if (arg == 1) { + // DECSED and DECSEL cannot erase characters. + mEffect |= TextStyle.CHARACTER_ATTRIBUTE_PROTECTED; + } else { + unknownSequence(b); + } + } else { + unknownSequence(b); + } + break; + case ESC_CSI_SINGLE_QUOTE: + if (b == '}') { // Insert Ps Column(s) (default = 1) (DECIC), VT420 and up. + int columnsAfterCursor = mRightMargin - mCursorCol; + int columnsToInsert = Math.min(getArg0(1), columnsAfterCursor); + int columnsToMove = columnsAfterCursor - columnsToInsert; + mScreen.blockCopy(mCursorCol, 0, columnsToMove, mRows, mCursorCol + columnsToInsert, 0); + blockClear(mCursorCol, 0, columnsToInsert, mRows); + } else if (b == '~') { // Delete Ps Column(s) (default = 1) (DECDC), VT420 and up. + int columnsAfterCursor = mRightMargin - mCursorCol; + int columnsToDelete = Math.min(getArg0(1), columnsAfterCursor); + int columnsToMove = columnsAfterCursor - columnsToDelete; + mScreen.blockCopy(mCursorCol + columnsToDelete, 0, columnsToMove, mRows, mCursorCol, 0); + } else { + unknownSequence(b); + } + break; + case ESC_PERCENT: + break; + case ESC_OSC: + doOsc(b); + break; + case ESC_OSC_ESC: + doOscEsc(b); + break; + case ESC_P: + doDeviceControl(b); + break; + case ESC_CSI_QUESTIONMARK_ARG_DOLLAR: + if (b == 'p') { + // Request DEC private mode (DECRQM). + int mode = getArg0(0); + int value; + if (mode == 47 || mode == 1047 || mode == 1049) { + // This state is carried by mScreen pointer. + value = (mScreen == mAltBuffer) ? 1 : 2; + } else { + int internalBit = mapDecSetBitToInternalBit(mode); + if (internalBit != -1) { + value = isDecsetInternalBitSet(internalBit) ? 1 : 2; // 1=set, 2=reset. + } else { + Logger.logError(mClient, LOG_TAG, "Got DECRQM for unrecognized private DEC mode=" + mode); + value = 0; // 0=not recognized, 3=permanently set, 4=permanently reset + } + } + mSession.write(String.format(Locale.US, "\033[?%d;%d$y", mode, value)); + } else { + unknownSequence(b); + } + break; + case ESC_CSI_ARGS_SPACE: + int arg = getArg0(0); + switch (b) { + case 'q': // "${CSI}${STYLE} q" - set cursor style (http://www.vt100.net/docs/vt510-rm/DECSCUSR). + switch (arg) { + case 0: // Blinking block. + case 1: // Blinking block. + case 2: // Steady block. + mCursorStyle = TERMINAL_CURSOR_STYLE_BLOCK; + break; + case 3: // Blinking underline. + case 4: // Steady underline. + mCursorStyle = TERMINAL_CURSOR_STYLE_UNDERLINE; + break; + case 5: // Blinking bar (xterm addition). + case 6: // Steady bar (xterm addition). + mCursorStyle = TERMINAL_CURSOR_STYLE_BAR; + break; + } + break; + case 't': + case 'u': + // Set margin-bell volume - ignore. + break; + default: + unknownSequence(b); + } + break; + case ESC_CSI_ARGS_ASTERIX: + int attributeChangeExtent = getArg0(0); + if (b == 'x' && (attributeChangeExtent >= 0 && attributeChangeExtent <= 2)) { + // Select attribute change extent (DECSACE - http://www.vt100.net/docs/vt510-rm/DECSACE). + setDecsetinternalBit(DECSET_BIT_RECTANGULAR_CHANGEATTRIBUTE, attributeChangeExtent == 2); + } else { + unknownSequence(b); + } + break; + default: + unknownSequence(b); + break; + } + if (!mContinueSequence) mEscapeState = ESC_NONE; + break; + } + } + + /** When in {@link #ESC_P} ("device control") sequence. */ + private void doDeviceControl(int b) { + switch (b) { + case (byte) '\\': // End of ESC \ string Terminator + { + String dcs = mOSCOrDeviceControlArgs.toString(); + // DCS $ q P t ST. Request Status String (DECRQSS) + if (dcs.startsWith("$q")) { + if (dcs.equals("$q\"p")) { + // DECSCL, conformance level, http://www.vt100.net/docs/vt510-rm/DECSCL: + String csiString = "64;1\"p"; + mSession.write("\033P1$r" + csiString + "\033\\"); + } else { + finishSequenceAndLogError("Unrecognized DECRQSS string: '" + dcs + "'"); + } + } else if (dcs.startsWith("+q")) { + // Request Termcap/Terminfo String. The string following the "q" is a list of names encoded in + // hexadecimal (2 digits per character) separated by ; which correspond to termcap or terminfo key + // names. + // Two special features are also recognized, which are not key names: Co for termcap colors (or colors + // for terminfo colors), and TN for termcap name (or name for terminfo name). + // xterm responds with DCS 1 + r P t ST for valid requests, adding to P t an = , and the value of the + // corresponding string that xterm would send, or DCS 0 + r P t ST for invalid requests. The strings are + // encoded in hexadecimal (2 digits per character). + // Example: + // :kr=\EOC: ks=\E[?1h\E=: ku=\EOA: le=^H:mb=\E[5m:md=\E[1m:\ + // where + // kd=down-arrow key + // kl=left-arrow key + // kr=right-arrow key + // ku=up-arrow key + // #2=key_shome, "shifted home" + // #4=key_sleft, "shift arrow left" + // %i=key_sright, "shift arrow right" + // *7=key_send, "shifted end" + // k1=F1 function key + + // Example: Request for ku is "ESC P + q 6 b 7 5 ESC \", where 6b7d=ku in hexadecimal. + // Xterm response in normal cursor mode: + // "<27> P 1 + r 6 b 7 5 = 1 B 5 B 4 1" where 0x1B 0x5B 0x41 = 27 91 65 = ESC [ A + // Xterm response in application cursor mode: + // "<27> P 1 + r 6 b 7 5 = 1 B 5 B 4 1" where 0x1B 0x4F 0x41 = 27 91 65 = ESC 0 A + + // #4 is "shift arrow left": + // *** Device Control (DCS) for '#4'- 'ESC P + q 23 34 ESC \' + // Response: <27> P 1 + r 2 3 3 4 = 1 B 5 B 3 1 3 B 3 2 4 4 <27> \ + // where 0x1B 0x5B 0x31 0x3B 0x32 0x44 = ESC [ 1 ; 2 D + // which we find in: TermKeyListener.java: KEY_MAP.put(KEYMOD_SHIFT | KEYCODE_DPAD_LEFT, "\033[1;2D"); + + // See http://h30097.www3.hp.com/docs/base_doc/DOCUMENTATION/V40G_HTML/MAN/MAN4/0178____.HTM for what to + // respond, as well as http://www.freebsd.org/cgi/man.cgi?query=termcap&sektion=5#CAPABILITIES for + // the meaning of e.g. "ku", "kd", "kr", "kl" + + for (String part : dcs.substring(2).split(";")) { + if (part.length() % 2 == 0) { + StringBuilder transBuffer = new StringBuilder(); + char c; + for (int i = 0; i < part.length(); i += 2) { + try { + c = (char) Long.decode("0x" + part.charAt(i) + "" + part.charAt(i + 1)).longValue(); + } catch (NumberFormatException e) { + Logger.logStackTraceWithMessage(mClient, LOG_TAG, "Invalid device termcap/terminfo encoded name \"" + part + "\"", e); + continue; + } + transBuffer.append(c); + } + + String trans = transBuffer.toString(); + String responseValue; + switch (trans) { + case "Co": + case "colors": + responseValue = "256"; // Number of colors. + break; + case "TN": + case "name": + responseValue = "xterm"; + break; + default: + responseValue = KeyHandler.getCodeFromTermcap(trans, isDecsetInternalBitSet(DECSET_BIT_APPLICATION_CURSOR_KEYS), + isDecsetInternalBitSet(DECSET_BIT_APPLICATION_KEYPAD)); + break; + } + if (responseValue == null) { + switch (trans) { + case "%1": // Help key - ignore + case "&8": // Undo key - ignore. + break; + default: + Logger.logWarn(mClient, LOG_TAG, "Unhandled termcap/terminfo name: '" + trans + "'"); + } + // Respond with invalid request: + mSession.write("\033P0+r" + part + "\033\\"); + } else { + StringBuilder hexEncoded = new StringBuilder(); + for (int j = 0; j < responseValue.length(); j++) { + hexEncoded.append(String.format("%02X", (int) responseValue.charAt(j))); + } + mSession.write("\033P1+r" + part + "=" + hexEncoded + "\033\\"); + } + } else { + Logger.logError(mClient, LOG_TAG, "Invalid device termcap/terminfo name of odd length: " + part); + } + } + } else { + if (LOG_ESCAPE_SEQUENCES) + Logger.logError(mClient, LOG_TAG, "Unrecognized device control string: " + dcs); + } + finishSequence(); + } + break; + default: + if (mOSCOrDeviceControlArgs.length() > MAX_OSC_STRING_LENGTH) { + // Too long. + mOSCOrDeviceControlArgs.setLength(0); + finishSequence(); + } else { + mOSCOrDeviceControlArgs.appendCodePoint(b); + continueSequence(mEscapeState); + } + } + } + + /** + * When in {@link #ESC_APC} (APC, Application Program Command) sequence. + */ + private void doApc(int b) { + if (b == 27) { + continueSequence(ESC_APC_ESCAPE); + } + // Eat APC sequences silently for now. + } + + /** + * When in {@link #ESC_APC} (APC, Application Program Command) sequence. + */ + private void doApcEscape(int b) { + if (b == '\\') { + // A String Terminator (ST), ending the APC escape sequence. + finishSequence(); + } else { + // The Escape character was not the start of a String Terminator (ST), + // but instead just data inside of the APC escape sequence. + continueSequence(ESC_APC); + } + } + + private int nextTabStop(int numTabs) { + for (int i = mCursorCol + 1; i < mColumns; i++) + if (mTabStop[i] && --numTabs == 0) return Math.min(i, mRightMargin); + return mRightMargin - 1; + } + + /** + * Process byte while in the {@link #ESC_CSI_UNSUPPORTED_PARAMETER_BYTE} or + * {@link #ESC_CSI_UNSUPPORTED_INTERMEDIATE_BYTE} escape state. + * + * Parse unsupported parameter, intermediate and final bytes but ignore them. + * + * > For Control Sequence Introducer, ... the ESC [ is followed by + * > - any number (including none) of "parameter bytes" in the range 0x30–0x3F (ASCII 0–9:;<=>?), + * > - then by any number of "intermediate bytes" in the range 0x20–0x2F (ASCII space and !"#$%&'()*+,-./), + * > - then finally by a single "final byte" in the range 0x40–0x7E (ASCII @A–Z[\]^_`a–z{|}~). + * + * - https://en.wikipedia.org/wiki/ANSI_escape_code#Control_Sequence_Introducer_commands + * - https://invisible-island.net/xterm/ecma-48-parameter-format.html#section5.4 + */ + private void doCsiUnsupportedParameterOrIntermediateByte(int b) { + if (mEscapeState == ESC_CSI_UNSUPPORTED_PARAMETER_BYTE && b >= 0x30 && b <= 0x3F) { + // Supported `0–9:;>?` or unsupported `<=` parameter byte after an + // initial unsupported parameter byte in `doCsi()`, or a sequential parameter byte. + continueSequence(ESC_CSI_UNSUPPORTED_PARAMETER_BYTE); + } else if (b >= 0x20 && b <= 0x2F) { + // Optional intermediate byte `!"#$%&'()*+,-./` after parameter or intermediate byte. + continueSequence(ESC_CSI_UNSUPPORTED_INTERMEDIATE_BYTE); + } else if (b >= 0x40 && b <= 0x7E) { + // Final byte `@A–Z[\]^_`a–z{|}~` after parameter or intermediate byte. + // Calling `unknownSequence()` would log an error with only a final byte, so ignore it for now. + finishSequence(); + } else { + unknownSequence(b); + } + } + + /** Process byte while in the {@link #ESC_CSI_QUESTIONMARK} escape state. */ + private void doCsiQuestionMark(int b) { + switch (b) { + case 'u': // Kitty keyboard protocol: query current flags. + mSession.write("\033[?" + getKittyKeyboardFlags() + "u"); + break; + case 'J': // Selective erase in display (DECSED) - http://www.vt100.net/docs/vt510-rm/DECSED. + case 'K': // Selective erase in line (DECSEL) - http://vt100.net/docs/vt510-rm/DECSEL. + mAboutToAutoWrap = false; + int fillChar = ' '; + int startCol = -1; + int startRow = -1; + int endCol = -1; + int endRow = -1; + boolean justRow = (b == 'K'); + switch (getArg0(0)) { + case 0: // Erase from the active position to the end, inclusive (default). + startCol = mCursorCol; + startRow = mCursorRow; + endCol = mColumns; + endRow = justRow ? (mCursorRow + 1) : mRows; + break; + case 1: // Erase from start to the active position, inclusive. + startCol = 0; + startRow = justRow ? mCursorRow : 0; + endCol = mCursorCol + 1; + endRow = mCursorRow + 1; + break; + case 2: // Erase all of the display/line. + startCol = 0; + startRow = justRow ? mCursorRow : 0; + endCol = mColumns; + endRow = justRow ? (mCursorRow + 1) : mRows; + break; + default: + unknownSequence(b); + break; + } + long style = getStyle(); + for (int row = startRow; row < endRow; row++) { + for (int col = startCol; col < endCol; col++) { + if ((TextStyle.decodeEffect(mScreen.getStyleAt(row, col)) & TextStyle.CHARACTER_ATTRIBUTE_PROTECTED) == 0) + mScreen.setChar(col, row, fillChar, style); + } + } + break; + case 'h': + case 'l': + if (mArgIndex >= mArgs.length) mArgIndex = mArgs.length - 1; + for (int i = 0; i <= mArgIndex; i++) + doDecSetOrReset(b == 'h', mArgs[i]); + break; + case 'n': // Device Status Report (DSR, DEC-specific). + switch (getArg0(-1)) { + case 6: + // Extended Cursor Position (DECXCPR - http://www.vt100.net/docs/vt510-rm/DECXCPR). Page=1. + mSession.write(String.format(Locale.US, "\033[?%d;%d;1R", mCursorRow + 1, mCursorCol + 1)); + break; + default: + finishSequence(); + return; + } + break; + case 'r': + case 's': + if (mArgIndex >= mArgs.length) mArgIndex = mArgs.length - 1; + for (int i = 0; i <= mArgIndex; i++) { + int externalBit = mArgs[i]; + int internalBit = mapDecSetBitToInternalBit(externalBit); + if (internalBit == -1) { + Logger.logWarn(mClient, LOG_TAG, "Ignoring request to save/recall decset bit=" + externalBit); + } else { + if (b == 's') { + mSavedDecSetFlags |= internalBit; + } else { + doDecSetOrReset((mSavedDecSetFlags & internalBit) != 0, externalBit); + } + } + } + break; + case '$': + continueSequence(ESC_CSI_QUESTIONMARK_ARG_DOLLAR); + return; + default: + parseArg(b); + } + } + + public void doDecSetOrReset(boolean setting, int externalBit) { + int internalBit = mapDecSetBitToInternalBit(externalBit); + if (internalBit != -1) { + setDecsetinternalBit(internalBit, setting); + } + switch (externalBit) { + case 1: // Application Cursor Keys (DECCKM). + break; + case 3: // Set: 132 column mode (. Reset: 80 column mode. ANSI name: DECCOLM. + // We don't actually set/reset 132 cols, but we do want the side effects + // (FIXME: Should only do this if the 95 DECSET bit (DECNCSM) is set, and if changing value?): + // Sets the left, right, top and bottom scrolling margins to their default positions, which is important for + // the "reset" utility to really reset the terminal: + mLeftMargin = mTopMargin = 0; + mBottomMargin = mRows; + mRightMargin = mColumns; + // "DECCOLM resets vertical split screen mode (DECLRMM) to unavailable": + setDecsetinternalBit(DECSET_BIT_LEFTRIGHT_MARGIN_MODE, false); + // "Erases all data in page memory": + blockClear(0, 0, mColumns, mRows); + setCursorRowCol(0, 0); + break; + case 4: // DECSCLM-Scrolling Mode. Ignore. + break; + case 5: // Reverse video. No action. + break; + case 6: // Set: Origin Mode. Reset: Normal Cursor Mode. Ansi name: DECOM. + if (setting) setCursorPosition(0, 0); + break; + case 7: // Wrap-around bit, not specific action. + case 8: // Auto-repeat Keys (DECARM). Do not implement. + case 9: // X10 mouse reporting - outdated. Do not implement. + case 12: // Control cursor blinking - ignore. + case 25: // Hide/show cursor - no action needed, renderer will check with shouldCursorBeVisible(). + if (mClient != null) + mClient.onTerminalCursorStateChange(setting); + break; + case 40: // Allow 80 => 132 Mode, ignore. + case 45: // TODO: Reverse wrap-around. Implement??? + case 66: // Application keypad (DECNKM). + break; + case 69: // Left and right margin mode (DECLRMM). + if (!setting) { + mLeftMargin = 0; + mRightMargin = mColumns; + } + break; + case 1000: + case 1001: + case 1002: + case 1003: + case 1004: + case 1005: // UTF-8 mouse mode, ignore. + case 1006: // SGR Mouse Mode + case 1015: + case 1034: // Interpret "meta" key, sets eighth bit. + break; + case 1048: // Set: Save cursor as in DECSC. Reset: Restore cursor as in DECRC. + if (setting) + saveCursor(); + else + restoreCursor(); + break; + case 47: + case 1047: + case 1049: { + // Set: Save cursor as in DECSC and use Alternate Screen Buffer, clearing it first. + // Reset: Use Normal Screen Buffer and restore cursor as in DECRC. + TerminalBuffer newScreen = setting ? mAltBuffer : mMainBuffer; + if (newScreen != mScreen) { + boolean resized = !(newScreen.mColumns == mColumns && newScreen.mScreenRows == mRows); + if (setting) saveCursor(); + mScreen = newScreen; + if (!setting) { + int col = mSavedStateMain.mSavedCursorCol; + int row = mSavedStateMain.mSavedCursorRow; + restoreCursor(); + if (resized) { + // Restore cursor position _not_ clipped to current screen (let resizeScreen() handle that): + mCursorCol = col; + mCursorRow = row; + } + } + // Check if buffer size needs to be updated: + if (resized) resizeScreen(); + // Clear new screen if alt buffer: + if (newScreen == mAltBuffer) + newScreen.blockSet(0, 0, mColumns, mRows, ' ', getStyle()); + } + break; + } + case 2004: + // Bracketed paste mode - setting bit is enough. + break; + default: + unknownParameter(externalBit); + break; + } + } + + private void doCsiBiggerThan(int b) { + switch (b) { + case 'c': // "${CSI}>c" or "${CSI}>c". Secondary Device Attributes (DA2). + // Originally this was used for the terminal to respond with "identification code, firmware version level, + // and hardware options" (http://vt100.net/docs/vt510-rm/DA2), with the first "41" meaning the VT420 + // terminal type. This is not used anymore, but the second version level field has been changed by xterm + // to mean it's release number ("patch numbers" listed at http://invisible-island.net/xterm/xterm.log.html), + // and some applications use it as a feature check: + // * tmux used to have a "xterm won't reach version 500 for a while so set that as the upper limit" check, + // and then check "xterm_version > 270" if rectangular area operations such as DECCRA could be used. + // * vim checks xterm version number >140 for "Request termcap/terminfo string" functionality >276 for SGR + // mouse report. + // The third number is a keyboard identifier not used nowadays. + mSession.write("\033[>41;320;0c"); + break; + case 'm': + // https://bugs.launchpad.net/gnome-terminal/+bug/96676/comments/25 + // Depending on the first number parameter, this can set one of the xterm resources + // modifyKeyboard, modifyCursorKeys, modifyFunctionKeys and modifyOtherKeys. + // http://invisible-island.net/xterm/manpage/xterm.html#RESOURCES + + // * modifyKeyboard (parameter=1): + // Normally xterm makes a special case regarding modifiers (shift, control, etc.) to handle special keyboard + // layouts (legacy and vt220). This is done to provide compatible keyboards for DEC VT220 and related + // terminals that implement user-defined keys (UDK). + // The bits of the resource value selectively enable modification of the given category when these keyboards + // are selected. The default is "0": + // (0) The legacy/vt220 keyboards interpret only the Control-modifier when constructing numbered + // function-keys. Other special keys are not modified. + // (1) allows modification of the numeric keypad + // (2) allows modification of the editing keypad + // (4) allows modification of function-keys, overrides use of Shift-modifier for UDK. + // (8) allows modification of other special keys + + // * modifyCursorKeys (parameter=2): + // Tells how to handle the special case where Control-, Shift-, Alt- or Meta-modifiers are used to add a + // parameter to the escape sequence returned by a cursor-key. The default is "2". + // - Set it to -1 to disable it. + // - Set it to 0 to use the old/obsolete behavior. + // - Set it to 1 to prefix modified sequences with CSI. + // - Set it to 2 to force the modifier to be the second parameter if it would otherwise be the first. + // - Set it to 3 to mark the sequence with a ">" to hint that it is private. + + // * modifyFunctionKeys (parameter=3): + // Tells how to handle the special case where Control-, Shift-, Alt- or Meta-modifiers are used to add a + // parameter to the escape sequence returned by a (numbered) function- + // key. The default is "2". The resource values are similar to modifyCursorKeys: + // Set it to -1 to permit the user to use shift- and control-modifiers to construct function-key strings + // using the normal encoding scheme. + // - Set it to 0 to use the old/obsolete behavior. + // - Set it to 1 to prefix modified sequences with CSI. + // - Set it to 2 to force the modifier to be the second parameter if it would otherwise be the first. + // - Set it to 3 to mark the sequence with a ">" to hint that it is private. + // If modifyFunctionKeys is zero, xterm uses Control- and Shift-modifiers to allow the user to construct + // numbered function-keys beyond the set provided by the keyboard: + // (Control) adds the value given by the ctrlFKeys resource. + // (Shift) adds twice the value given by the ctrlFKeys resource. + // (Control/Shift) adds three times the value given by the ctrlFKeys resource. + // + // As a special case, legacy (when oldFunctionKeys is true) or vt220 (when sunKeyboard is true) + // keyboards interpret only the Control-modifier when constructing numbered function-keys. + // This is done to provide compatible keyboards for DEC VT220 and related terminals that + // implement user-defined keys (UDK). + + // * modifyOtherKeys (parameter=4): + // Like modifyCursorKeys, tells xterm to construct an escape sequence for other keys (such as "2") when + // modified by Control-, Alt- or Meta-modifiers. This feature does not apply to function keys and + // well-defined keys such as ESC or the control keys. The default is "0". + // (0) disables this feature. + // (1) enables this feature for keys except for those with well-known behavior, e.g., Tab, Backarrow and + // some special control character cases, e.g., Control-Space to make a NUL. + // (2) enables this feature for keys including the exceptions listed. + if (getArg(0, -1, false) == 4) { + mModifyOtherKeys = Math.max(0, Math.min(2, getArg(1, 0, false))); + } else { + Logger.logError(mClient, LOG_TAG, "(ignored) CSI > MODIFY RESOURCE: " + getArg0(-1) + " to " + getArg1(-1)); + } + break; + case 'u': // Kitty keyboard protocol: push flags. + pushKittyFlags(Math.max(0, getArg(0, 0, false))); + break; + default: + parseArg(b); + break; + } + } + + private void startEscapeSequence() { + mEscapeState = ESC; + mArgIndex = 0; + Arrays.fill(mArgs, -1); + mArgsSubParamsBitSet = 0; + } + + private void doLinefeed() { + boolean belowScrollingRegion = mCursorRow >= mBottomMargin; + int newCursorRow = mCursorRow + 1; + if (belowScrollingRegion) { + // Move down (but not scroll) as long as we are above the last row. + if (mCursorRow != mRows - 1) { + setCursorRow(newCursorRow); + } + } else { + if (newCursorRow == mBottomMargin) { + scrollDownOneLine(); + newCursorRow = mBottomMargin - 1; + } + setCursorRow(newCursorRow); + } + } + + private void continueSequence(int state) { + mEscapeState = state; + mContinueSequence = true; + } + + private void doEscPound(int b) { + switch (b) { + case '8': // Esc # 8 - DEC screen alignment test - fill screen with E's. + mScreen.blockSet(0, 0, mColumns, mRows, 'E', getStyle()); + break; + default: + unknownSequence(b); + break; + } + } + + /** Encountering a character in the {@link #ESC} state. */ + private void doEsc(int b) { + switch (b) { + case '#': + continueSequence(ESC_POUND); + break; + case '(': + continueSequence(ESC_SELECT_LEFT_PAREN); + break; + case ')': + continueSequence(ESC_SELECT_RIGHT_PAREN); + break; + case '6': // Back index (http://www.vt100.net/docs/vt510-rm/DECBI). Move left, insert blank column if start. + if (mCursorCol > mLeftMargin) { + mCursorCol--; + } else { + int rows = mBottomMargin - mTopMargin; + mScreen.blockCopy(mLeftMargin, mTopMargin, mRightMargin - mLeftMargin - 1, rows, mLeftMargin + 1, mTopMargin); + mScreen.blockSet(mLeftMargin, mTopMargin, 1, rows, ' ', TextStyle.encode(mForeColor, mBackColor, 0)); + } + break; + case '7': // DECSC save cursor - http://www.vt100.net/docs/vt510-rm/DECSC + saveCursor(); + break; + case '8': // DECRC restore cursor - http://www.vt100.net/docs/vt510-rm/DECRC + restoreCursor(); + break; + case '9': // Forward Index (http://www.vt100.net/docs/vt510-rm/DECFI). Move right, insert blank column if end. + if (mCursorCol < mRightMargin - 1) { + mCursorCol++; + } else { + int rows = mBottomMargin - mTopMargin; + mScreen.blockCopy(mLeftMargin + 1, mTopMargin, mRightMargin - mLeftMargin - 1, rows, mLeftMargin, mTopMargin); + mScreen.blockSet(mRightMargin - 1, mTopMargin, 1, rows, ' ', TextStyle.encode(mForeColor, mBackColor, 0)); + } + break; + case 'c': // RIS - Reset to Initial State (http://vt100.net/docs/vt510-rm/RIS). + reset(); + mMainBuffer.clearTranscript(); + blockClear(0, 0, mColumns, mRows); + setCursorPosition(0, 0); + break; + case 'D': // INDEX + doLinefeed(); + break; + case 'E': // Next line (http://www.vt100.net/docs/vt510-rm/NEL). + setCursorCol(isDecsetInternalBitSet(DECSET_BIT_ORIGIN_MODE) ? mLeftMargin : 0); + doLinefeed(); + break; + case 'F': // Cursor to lower-left corner of screen + setCursorRowCol(0, mBottomMargin - 1); + break; + case 'H': // Tab set + mTabStop[mCursorCol] = true; + break; + case 'M': // "${ESC}M" - reverse index (RI). + // http://www.vt100.net/docs/vt100-ug/chapter3.html: "Move the active position to the same horizontal + // position on the preceding line. If the active position is at the top margin, a scroll down is performed". + if (mCursorRow <= mTopMargin) { + mScreen.blockCopy(mLeftMargin, mTopMargin, mRightMargin - mLeftMargin, mBottomMargin - (mTopMargin + 1), mLeftMargin, mTopMargin + 1); + blockClear(mLeftMargin, mTopMargin, mRightMargin - mLeftMargin); + } else { + mCursorRow--; + } + break; + case 'N': // SS2, ignore. + case '0': // SS3, ignore. + break; + case 'P': // Device control string + mOSCOrDeviceControlArgs.setLength(0); + continueSequence(ESC_P); + break; + case '[': + continueSequence(ESC_CSI); + break; + case '=': // DECKPAM + setDecsetinternalBit(DECSET_BIT_APPLICATION_KEYPAD, true); + break; + case ']': // OSC + mOSCOrDeviceControlArgs.setLength(0); + continueSequence(ESC_OSC); + break; + case '>': // DECKPNM + setDecsetinternalBit(DECSET_BIT_APPLICATION_KEYPAD, false); + break; + case '_': // APC - Application Program Command. + continueSequence(ESC_APC); + break; + default: + unknownSequence(b); + break; + } + } + + /** DECSC save cursor - http://www.vt100.net/docs/vt510-rm/DECSC . See {@link #restoreCursor()}. */ + private void saveCursor() { + SavedScreenState state = (mScreen == mMainBuffer) ? mSavedStateMain : mSavedStateAlt; + state.mSavedCursorRow = mCursorRow; + state.mSavedCursorCol = mCursorCol; + state.mSavedEffect = mEffect; + state.mSavedForeColor = mForeColor; + state.mSavedBackColor = mBackColor; + state.mSavedDecFlags = mCurrentDecSetFlags; + state.mUseLineDrawingG0 = mUseLineDrawingG0; + state.mUseLineDrawingG1 = mUseLineDrawingG1; + state.mUseLineDrawingUsesG0 = mUseLineDrawingUsesG0; + } + + /** DECRS restore cursor - http://www.vt100.net/docs/vt510-rm/DECRC. See {@link #saveCursor()}. */ + private void restoreCursor() { + SavedScreenState state = (mScreen == mMainBuffer) ? mSavedStateMain : mSavedStateAlt; + setCursorRowCol(state.mSavedCursorRow, state.mSavedCursorCol); + mEffect = state.mSavedEffect; + mForeColor = state.mSavedForeColor; + mBackColor = state.mSavedBackColor; + int mask = (DECSET_BIT_AUTOWRAP | DECSET_BIT_ORIGIN_MODE); + mCurrentDecSetFlags = (mCurrentDecSetFlags & ~mask) | (state.mSavedDecFlags & mask); + mUseLineDrawingG0 = state.mUseLineDrawingG0; + mUseLineDrawingG1 = state.mUseLineDrawingG1; + mUseLineDrawingUsesG0 = state.mUseLineDrawingUsesG0; + } + + /** Following a CSI - Control Sequence Introducer, "\033[". {@link #ESC_CSI}. */ + private void doCsi(int b) { + switch (b) { + case '!': + continueSequence(ESC_CSI_EXCLAMATION); + break; + case '"': + continueSequence(ESC_CSI_DOUBLE_QUOTE); + break; + case '\'': + continueSequence(ESC_CSI_SINGLE_QUOTE); + break; + case '$': + continueSequence(ESC_CSI_DOLLAR); + break; + case '*': + continueSequence(ESC_CSI_ARGS_ASTERIX); + break; + case '@': { + // "CSI{n}@" - Insert ${n} space characters (ICH) - http://www.vt100.net/docs/vt510-rm/ICH. + mAboutToAutoWrap = false; + int columnsAfterCursor = mColumns - mCursorCol; + int spacesToInsert = Math.min(getArg0(1), columnsAfterCursor); + int charsToMove = columnsAfterCursor - spacesToInsert; + mScreen.blockCopy(mCursorCol, mCursorRow, charsToMove, 1, mCursorCol + spacesToInsert, mCursorRow); + blockClear(mCursorCol, mCursorRow, spacesToInsert); + } + break; + case 'A': // "CSI${n}A" - Cursor up (CUU) ${n} rows. + setCursorRow(Math.max(0, mCursorRow - getArg0(1))); + break; + case 'B': // "CSI${n}B" - Cursor down (CUD) ${n} rows. + setCursorRow(Math.min(mRows - 1, mCursorRow + getArg0(1))); + break; + case 'C': // "CSI${n}C" - Cursor forward (CUF). + case 'a': // "CSI${n}a" - Horizontal position relative (HPR). From ISO-6428/ECMA-48. + setCursorCol(Math.min(mRightMargin - 1, mCursorCol + getArg0(1))); + break; + case 'D': // "CSI${n}D" - Cursor backward (CUB) ${n} columns. + setCursorCol(Math.max(mLeftMargin, mCursorCol - getArg0(1))); + break; + case 'E': // "CSI{n}E - Cursor Next Line (CNL). From ISO-6428/ECMA-48. + setCursorPosition(0, mCursorRow + getArg0(1)); + break; + case 'F': // "CSI{n}F - Cursor Previous Line (CPL). From ISO-6428/ECMA-48. + setCursorPosition(0, mCursorRow - getArg0(1)); + break; + case 'G': // "CSI${n}G" - Cursor horizontal absolute (CHA) to column ${n}. + setCursorCol(Math.min(Math.max(1, getArg0(1)), mColumns) - 1); + break; + case 'H': // "${CSI}${ROW};${COLUMN}H" - Cursor position (CUP). + case 'f': // "${CSI}${ROW};${COLUMN}f" - Horizontal and Vertical Position (HVP). + setCursorPosition(getArg1(1) - 1, getArg0(1) - 1); + break; + case 'I': // Cursor Horizontal Forward Tabulation (CHT). Move the active position n tabs forward. + setCursorCol(nextTabStop(getArg0(1))); + break; + case 'J': // "${CSI}${0,1,2,3}J" - Erase in Display (ED) + // ED ignores the scrolling margins. + switch (getArg0(0)) { + case 0: // Erase from the active position to the end of the screen, inclusive (default). + blockClear(mCursorCol, mCursorRow, mColumns - mCursorCol); + blockClear(0, mCursorRow + 1, mColumns, mRows - (mCursorRow + 1)); + break; + case 1: // Erase from start of the screen to the active position, inclusive. + blockClear(0, 0, mColumns, mCursorRow); + blockClear(0, mCursorRow, mCursorCol + 1); + break; + case 2: // Erase all of the display - all lines are erased, changed to single-width, and the cursor does not + // move.. + blockClear(0, 0, mColumns, mRows); + break; + case 3: // Delete all lines saved in the scrollback buffer (xterm etc) + mMainBuffer.clearTranscript(); + break; + default: + unknownSequence(b); + return; + } + mAboutToAutoWrap = false; + break; + case 'K': // "CSI{n}K" - Erase in line (EL). + switch (getArg0(0)) { + case 0: // Erase from the cursor to the end of the line, inclusive (default) + blockClear(mCursorCol, mCursorRow, mColumns - mCursorCol); + break; + case 1: // Erase from the start of the screen to the cursor, inclusive. + blockClear(0, mCursorRow, mCursorCol + 1); + break; + case 2: // Erase all of the line. + blockClear(0, mCursorRow, mColumns); + break; + default: + unknownSequence(b); + return; + } + mAboutToAutoWrap = false; + break; + case 'L': // "${CSI}{N}L" - insert ${N} lines (IL). + { + int linesAfterCursor = mBottomMargin - mCursorRow; + int linesToInsert = Math.min(getArg0(1), linesAfterCursor); + int linesToMove = linesAfterCursor - linesToInsert; + mScreen.blockCopy(0, mCursorRow, mColumns, linesToMove, 0, mCursorRow + linesToInsert); + blockClear(0, mCursorRow, mColumns, linesToInsert); + } + break; + case 'M': // "${CSI}${N}M" - delete N lines (DL). + { + mAboutToAutoWrap = false; + int linesAfterCursor = mBottomMargin - mCursorRow; + int linesToDelete = Math.min(getArg0(1), linesAfterCursor); + int linesToMove = linesAfterCursor - linesToDelete; + mScreen.blockCopy(0, mCursorRow + linesToDelete, mColumns, linesToMove, 0, mCursorRow); + blockClear(0, mCursorRow + linesToMove, mColumns, linesToDelete); + } + break; + case 'P': // "${CSI}{N}P" - delete ${N} characters (DCH). + { + // http://www.vt100.net/docs/vt510-rm/DCH: "If ${N} is greater than the number of characters between the + // cursor and the right margin, then DCH only deletes the remaining characters. + // As characters are deleted, the remaining characters between the cursor and right margin move to the left. + // Character attributes move with the characters. The terminal adds blank spaces with no visual character + // attributes at the right margin. DCH has no effect outside the scrolling margins." + mAboutToAutoWrap = false; + int cellsAfterCursor = mColumns - mCursorCol; + int cellsToDelete = Math.min(getArg0(1), cellsAfterCursor); + int cellsToMove = cellsAfterCursor - cellsToDelete; + mScreen.blockCopy(mCursorCol + cellsToDelete, mCursorRow, cellsToMove, 1, mCursorCol, mCursorRow); + blockClear(mCursorCol + cellsToMove, mCursorRow, cellsToDelete); + } + break; + case 'S': { // "${CSI}${N}S" - scroll up ${N} lines (default = 1) (SU). + final int linesToScroll = getArg0(1); + for (int i = 0; i < linesToScroll; i++) + scrollDownOneLine(); + break; + } + case 'T': + if (mArgIndex == 0) { + // "${CSI}${N}T" - Scroll down N lines (default = 1) (SD). + // http://vt100.net/docs/vt510-rm/SD: "N is the number of lines to move the user window up in page + // memory. N new lines appear at the top of the display. N old lines disappear at the bottom of the + // display. You cannot pan past the top margin of the current page". + final int linesToScrollArg = getArg0(1); + final int linesBetweenTopAndBottomMargins = mBottomMargin - mTopMargin; + final int linesToScroll = Math.min(linesBetweenTopAndBottomMargins, linesToScrollArg); + mScreen.blockCopy(mLeftMargin, mTopMargin, mRightMargin - mLeftMargin, linesBetweenTopAndBottomMargins - linesToScroll, mLeftMargin, mTopMargin + linesToScroll); + blockClear(mLeftMargin, mTopMargin, mRightMargin - mLeftMargin, linesToScroll); + } else { + // "${CSI}${func};${startx};${starty};${firstrow};${lastrow}T" - initiate highlight mouse tracking. + unimplementedSequence(b); + } + break; + case 'X': // "${CSI}${N}X" - Erase ${N:=1} character(s) (ECH). FIXME: Clears character attributes? + mAboutToAutoWrap = false; + mScreen.blockSet(mCursorCol, mCursorRow, Math.min(getArg0(1), mColumns - mCursorCol), 1, ' ', getStyle()); + break; + case 'Z': // Cursor Backward Tabulation (CBT). Move the active position n tabs backward. + int numberOfTabs = getArg0(1); + int newCol = mLeftMargin; + for (int i = mCursorCol - 1; i >= 0; i--) + if (mTabStop[i]) { + if (--numberOfTabs == 0) { + newCol = Math.max(i, mLeftMargin); + break; + } + } + mCursorCol = newCol; + break; + case '?': // Esc [ ? -- start of a private parameter byte + continueSequence(ESC_CSI_QUESTIONMARK); + break; + case '>': // "Esc [ >" -- start of a private parameter byte + continueSequence(ESC_CSI_BIGGERTHAN); + break; + case '<': // "Esc [ <" -- kitty keyboard protocol pop + continueSequence(ESC_CSI_LESSTHAN); + break; + case '=': // "Esc [ =" -- kitty keyboard protocol set + continueSequence(ESC_CSI_EQUALS); + break; + case '`': // Horizontal position absolute (HPA - http://www.vt100.net/docs/vt510-rm/HPA). + setCursorColRespectingOriginMode(getArg0(1) - 1); + break; + case 'b': // Repeat the preceding graphic character Ps times (REP). + if (mLastEmittedCodePoint == -1) break; + final int numRepeat = getArg0(1); + for (int i = 0; i < numRepeat; i++) emitCodePoint(mLastEmittedCodePoint); + break; + case 'c': // Primary Device Attributes (http://www.vt100.net/docs/vt510-rm/DA1) if argument is missing or zero. + // 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"); + break; + case 'd': // ESC [ Pn d - Vert Position Absolute + setCursorRow(Math.min(Math.max(1, getArg0(1)), mRows) - 1); + break; + case 'e': // Vertical Position Relative (VPR). From ISO-6429 (ECMA-48). + setCursorPosition(mCursorCol, mCursorRow + getArg0(1)); + break; + // case 'f': "${CSI}${ROW};${COLUMN}f" - Horizontal and Vertical Position (HVP). Grouped with case 'H'. + case 'g': // Clear tab stop + switch (getArg0(0)) { + case 0: + mTabStop[mCursorCol] = false; + break; + case 3: + for (int i = 0; i < mColumns; i++) { + mTabStop[i] = false; + } + break; + default: + // Specified to have no effect. + break; + } + break; + case 'h': // Set Mode + doSetMode(true); + break; + case 'l': // Reset Mode + doSetMode(false); + break; + case 'm': // Esc [ Pn m - character attributes. (can have up to 16 numerical arguments) + selectGraphicRendition(); + break; + case 'n': // Esc [ Pn n - ECMA-48 Status Report Commands + // sendDeviceAttributes() + switch (getArg0(0)) { + case 5: // Device status report (DSR): + // Answer is ESC [ 0 n (Terminal OK). + byte[] dsr = {(byte) 27, (byte) '[', (byte) '0', (byte) 'n'}; + mSession.write(dsr, 0, dsr.length); + break; + case 6: // Cursor position report (CPR): + // Answer is ESC [ y ; x R, where x,y is + // the cursor location. + mSession.write(String.format(Locale.US, "\033[%d;%dR", mCursorRow + 1, mCursorCol + 1)); + break; + default: + break; + } + break; + case 'r': // "CSI${top};${bottom}r" - set top and bottom Margins (DECSTBM). + { + // https://vt100.net/docs/vt510-rm/DECSTBM.html + // The top margin defaults to 1, the bottom margin defaults to mRows. + // The escape sequence numbers top 1..23, but we number top 0..22. + // The escape sequence numbers bottom 2..24, and so do we (because we use a zero based numbering + // scheme, but we store the first line below the bottom-most scrolling line. + // As a result, we adjust the top line by -1, but we leave the bottom line alone. + // Also require that top + 2 <= bottom. + mTopMargin = Math.max(0, Math.min(getArg0(1) - 1, mRows - 2)); + mBottomMargin = Math.max(mTopMargin + 2, Math.min(getArg1(mRows), mRows)); + + // DECSTBM moves the cursor to column 1, line 1 of the page respecting origin mode. + setCursorPosition(0, 0); + } + break; + case 's': + if (isDecsetInternalBitSet(DECSET_BIT_LEFTRIGHT_MARGIN_MODE)) { + // Set left and right margins (DECSLRM - http://www.vt100.net/docs/vt510-rm/DECSLRM). + mLeftMargin = Math.min(getArg0(1) - 1, mColumns - 2); + mRightMargin = Math.max(mLeftMargin + 1, Math.min(getArg1(mColumns), mColumns)); + // DECSLRM moves the cursor to column 1, line 1 of the page. + setCursorPosition(0, 0); + } else { + // Save cursor (ANSI.SYS), available only when DECLRMM is disabled. + saveCursor(); + } + break; + case 't': // Window manipulation (from dtterm, as well as extensions) + switch (getArg0(0)) { + case 11: // Report xterm window state. If the xterm window is open (non-iconified), it returns CSI 1 t . + mSession.write("\033[1t"); + break; + case 13: // Report xterm window position. Result is CSI 3 ; x ; y t + mSession.write("\033[3;0;0t"); + break; + case 14: // Report xterm window in pixels. Result is CSI 4 ; height ; width t + mSession.write(String.format(Locale.US, "\033[4;%d;%dt", mRows * mCellHeightPixels, mColumns * mCellWidthPixels)); + break; + case 16: // Report xterm character cell size in pixels. Result is CSI 6 ; height ; width t + mSession.write(String.format(Locale.US, "\033[6;%d;%dt", mCellHeightPixels, mCellWidthPixels)); + break; + case 18: // Report the size of the text area in characters. Result is CSI 8 ; height ; width t + mSession.write(String.format(Locale.US, "\033[8;%d;%dt", mRows, mColumns)); + break; + case 19: // Report the size of the screen in characters. Result is CSI 9 ; height ; width t + // We report the same size as the view, since it's the view really isn't resizable from the shell. + mSession.write(String.format(Locale.US, "\033[9;%d;%dt", mRows, mColumns)); + break; + case 20: // Report xterm windows icon label. Result is OSC L label ST. Disabled due to security concerns: + mSession.write("\033]LIconLabel\033\\"); + break; + case 21: // Report xterm windows title. Result is OSC l label ST. Disabled due to security concerns: + mSession.write("\033]l\033\\"); + break; + case 22: + // 22;0 -> Save xterm icon and window title on stack. + // 22;1 -> Save xterm icon title on stack. + // 22;2 -> Save xterm window title on stack. + mTitleStack.push(mTitle); + if (mTitleStack.size() > 20) { + // Limit size + mTitleStack.remove(0); + } + break; + case 23: // Like 22 above but restore from stack. + if (!mTitleStack.isEmpty()) setTitle(mTitleStack.pop()); + break; + default: + // Ignore window manipulation. + break; + } + break; + case 'u': // Restore cursor (ANSI.SYS). + restoreCursor(); + break; + case ' ': + continueSequence(ESC_CSI_ARGS_SPACE); + break; + default: + parseArg(b); + break; + } + } + + /** Select Graphic Rendition (SGR) - see http://en.wikipedia.org/wiki/ANSI_escape_code#graphics. */ + private void selectGraphicRendition() { + if (mArgIndex >= mArgs.length) mArgIndex = mArgs.length - 1; + for (int i = 0; i <= mArgIndex; i++) { + // Skip leading sub parameters: + if ((mArgsSubParamsBitSet & (1 << i)) != 0) { + continue; + } + + int code = getArg(i, 0, false); + if (code < 0) { + if (mArgIndex > 0) { + continue; + } else { + code = 0; + } + } + if (code == 0) { // reset + mForeColor = TextStyle.COLOR_INDEX_FOREGROUND; + mBackColor = TextStyle.COLOR_INDEX_BACKGROUND; + mEffect = 0; + } else if (code == 1) { + mEffect |= TextStyle.CHARACTER_ATTRIBUTE_BOLD; + } else if (code == 2) { + mEffect |= TextStyle.CHARACTER_ATTRIBUTE_DIM; + } else if (code == 3) { + mEffect |= TextStyle.CHARACTER_ATTRIBUTE_ITALIC; + } else if (code == 4) { + if (i + 1 <= mArgIndex && ((mArgsSubParamsBitSet & (1 << (i + 1))) != 0)) { + // Sub parameter, see https://sw.kovidgoyal.net/kitty/underlines/ + i++; + if (mArgs[i] == 0) { + // No underline. + mEffect &= ~TextStyle.CHARACTER_ATTRIBUTE_UNDERLINE; + } else { + // Different variations of underlines: https://sw.kovidgoyal.net/kitty/underlines/ + mEffect |= TextStyle.CHARACTER_ATTRIBUTE_UNDERLINE; + } + } else { + mEffect |= TextStyle.CHARACTER_ATTRIBUTE_UNDERLINE; + } + } else if (code == 5) { + mEffect |= TextStyle.CHARACTER_ATTRIBUTE_BLINK; + } else if (code == 7) { + mEffect |= TextStyle.CHARACTER_ATTRIBUTE_INVERSE; + } else if (code == 8) { + mEffect |= TextStyle.CHARACTER_ATTRIBUTE_INVISIBLE; + } else if (code == 9) { + mEffect |= TextStyle.CHARACTER_ATTRIBUTE_STRIKETHROUGH; + } else if (code == 10) { + // Exit alt charset (TERM=linux) - ignore. + } else if (code == 11) { + // Enter alt charset (TERM=linux) - ignore. + } else if (code == 22) { // Normal color or intensity, neither bright, bold nor faint. + mEffect &= ~(TextStyle.CHARACTER_ATTRIBUTE_BOLD | TextStyle.CHARACTER_ATTRIBUTE_DIM); + } else if (code == 23) { // not italic, but rarely used as such; clears standout with TERM=screen + mEffect &= ~TextStyle.CHARACTER_ATTRIBUTE_ITALIC; + } else if (code == 24) { // underline: none + mEffect &= ~TextStyle.CHARACTER_ATTRIBUTE_UNDERLINE; + } else if (code == 25) { // blink: none + mEffect &= ~TextStyle.CHARACTER_ATTRIBUTE_BLINK; + } else if (code == 27) { // image: positive + mEffect &= ~TextStyle.CHARACTER_ATTRIBUTE_INVERSE; + } else if (code == 28) { + mEffect &= ~TextStyle.CHARACTER_ATTRIBUTE_INVISIBLE; + } else if (code == 29) { + mEffect &= ~TextStyle.CHARACTER_ATTRIBUTE_STRIKETHROUGH; + } else if (code >= 30 && code <= 37) { + mForeColor = code - 30; + } else if (code == 38 || code == 48 || code == 58) { + // Extended set foreground(38)/background(48)/underline(58) color. + // This is followed by either "2;$R;$G;$B" to set a 24-bit color or + // "5;$INDEX" to set an indexed color. + if (i + 2 > mArgIndex) continue; + int firstArg = mArgs[i + 1]; + if (firstArg == 2) { + if (i + 4 > mArgIndex) { + Logger.logWarn(mClient, LOG_TAG, "Too few CSI" + code + ";2 RGB arguments"); + } else { + int red = getArg(i + 2, 0, false); + int green = getArg(i + 3, 0, false); + int blue = getArg(i + 4, 0, false); + + if (red < 0 || green < 0 || blue < 0 || red > 255 || green > 255 || blue > 255) { + finishSequenceAndLogError("Invalid RGB: " + red + "," + green + "," + blue); + } else { + int argbColor = 0xff_00_00_00 | (red << 16) | (green << 8) | blue; + switch (code) { + case 38: mForeColor = argbColor; break; + case 48: mBackColor = argbColor; break; + case 58: mUnderlineColor = argbColor; break; + } + } + i += 4; // "2;P_r;P_g;P_r" + } + } else if (firstArg == 5) { + int color = getArg(i + 2, 0, false); + i += 2; // "5;P_s" + if (color >= 0 && color < TextStyle.NUM_INDEXED_COLORS) { + switch (code) { + case 38: mForeColor = color; break; + case 48: mBackColor = color; break; + case 58: mUnderlineColor = color; break; + } + } else { + if (LOG_ESCAPE_SEQUENCES) Logger.logWarn(mClient, LOG_TAG, "Invalid color index: " + color); + } + } else { + finishSequenceAndLogError("Invalid ISO-8613-3 SGR first argument: " + firstArg); + } + } else if (code == 39) { // Set default foreground color. + mForeColor = TextStyle.COLOR_INDEX_FOREGROUND; + } else if (code >= 40 && code <= 47) { // Set background color. + mBackColor = code - 40; + } else if (code == 49) { // Set default background color. + mBackColor = TextStyle.COLOR_INDEX_BACKGROUND; + } else if (code == 59) { // Set default underline color. + mUnderlineColor = TextStyle.COLOR_INDEX_FOREGROUND; + } else if (code >= 90 && code <= 97) { // Bright foreground colors (aixterm codes). + mForeColor = code - 90 + 8; + } else if (code >= 100 && code <= 107) { // Bright background color (aixterm codes). + mBackColor = code - 100 + 8; + } else { + if (LOG_ESCAPE_SEQUENCES) + Logger.logWarn(mClient, LOG_TAG, String.format("SGR unknown code %d", code)); + } + } + } + + private void doOsc(int b) { + switch (b) { + case 7: // Bell. + doOscSetTextParameters("\007"); + break; + case 27: // Escape. + continueSequence(ESC_OSC_ESC); + break; + default: + collectOSCArgs(b); + break; + } + } + + private void doOscEsc(int b) { + switch (b) { + case '\\': + doOscSetTextParameters("\033\\"); + 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); + continueSequence(ESC_OSC); + break; + } + } + + /** An Operating System Controls (OSC) Set Text Parameters. May come here from BEL or ST. */ + private void doOscSetTextParameters(String bellOrStringTerminator) { + int value = -1; + String textParameter = ""; + // Extract initial $value from initial "$value;..." string. + for (int mOSCArgTokenizerIndex = 0; mOSCArgTokenizerIndex < mOSCOrDeviceControlArgs.length(); mOSCArgTokenizerIndex++) { + char b = mOSCOrDeviceControlArgs.charAt(mOSCArgTokenizerIndex); + if (b == ';') { + textParameter = mOSCOrDeviceControlArgs.substring(mOSCArgTokenizerIndex + 1); + break; + } else if (b >= '0' && b <= '9') { + value = ((value < 0) ? 0 : value * 10) + (b - '0'); + } else { + unknownSequence(b); + return; + } + } + + switch (value) { + case 0: // Change icon name and window title to T. + case 1: // Change icon name to T. + case 2: // Change window title to T. + setTitle(textParameter); + break; + case 4: + // P s = 4 ; c ; spec → Change Color Number c to the color specified by spec. This can be a name or RGB + // specification as per XParseColor. Any number of c name pairs may be given. The color numbers correspond + // to the ANSI colors 0-7, their bright versions 8-15, and if supported, the remainder of the 88-color or + // 256-color table. + // If a "?" is given rather than a name or RGB specification, xterm replies with a control sequence of the + // same form which can be used to set the corresponding color. Because more than one pair of color number + // and specification can be given in one control sequence, xterm can make more than one reply. + int colorIndex = -1; + int parsingPairStart = -1; + for (int i = 0; ; i++) { + boolean endOfInput = i == textParameter.length(); + char b = endOfInput ? ';' : textParameter.charAt(i); + if (b == ';') { + if (parsingPairStart < 0) { + parsingPairStart = i + 1; + } else { + if (colorIndex < 0 || colorIndex > 255) { + unknownSequence(b); + return; + } else { + mColors.tryParseColor(colorIndex, textParameter.substring(parsingPairStart, i)); + mSession.onColorsChanged(); + colorIndex = -1; + parsingPairStart = -1; + } + } + } else if (parsingPairStart >= 0) { + // We have passed a color index and are now going through color spec. + } else if (parsingPairStart < 0 && (b >= '0' && b <= '9')) { + colorIndex = ((colorIndex < 0) ? 0 : colorIndex * 10) + (b - '0'); + } else { + unknownSequence(b); + return; + } + if (endOfInput) break; + } + break; + case 10: // Set foreground color. + case 11: // Set background color. + case 12: // Set cursor color. + int specialIndex = TextStyle.COLOR_INDEX_FOREGROUND + (value - 10); + int lastSemiIndex = 0; + for (int charIndex = 0; ; charIndex++) { + boolean endOfInput = charIndex == textParameter.length(); + if (endOfInput || textParameter.charAt(charIndex) == ';') { + try { + String colorSpec = textParameter.substring(lastSemiIndex, charIndex); + if ("?".equals(colorSpec)) { + // Report current color in the same format xterm and gnome-terminal does. + int rgb = mColors.mCurrentColors[specialIndex]; + int r = (65535 * ((rgb & 0x00FF0000) >> 16)) / 255; + int g = (65535 * ((rgb & 0x0000FF00) >> 8)) / 255; + int b = (65535 * ((rgb & 0x000000FF))) / 255; + mSession.write("\033]" + value + ";rgb:" + String.format(Locale.US, "%04x", r) + "/" + String.format(Locale.US, "%04x", g) + "/" + + String.format(Locale.US, "%04x", b) + bellOrStringTerminator); + } else { + mColors.tryParseColor(specialIndex, colorSpec); + mSession.onColorsChanged(); + } + specialIndex++; + if (endOfInput || (specialIndex > TextStyle.COLOR_INDEX_CURSOR) || ++charIndex >= textParameter.length()) + break; + lastSemiIndex = charIndex; + } catch (NumberFormatException e) { + // Ignore. + } + } + } + break; + 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); + mSession.onCopyTextToClipboard(clipboardText); + } catch (Exception e) { + Logger.logError(mClient, LOG_TAG, "OSC Manipulate selection, invalid string '" + textParameter + ""); + } + break; + case 104: + // "104;$c" → Reset Color Number $c. It is reset to the color specified by the corresponding X + // resource. Any number of c parameters may be given. These parameters correspond to the ANSI colors 0-7, + // their bright versions 8-15, and if supported, the remainder of the 88-color or 256-color table. If no + // parameters are given, the entire table will be reset. + if (textParameter.isEmpty()) { + mColors.reset(); + mSession.onColorsChanged(); + } else { + int lastIndex = 0; + for (int charIndex = 0; ; charIndex++) { + boolean endOfInput = charIndex == textParameter.length(); + if (endOfInput || textParameter.charAt(charIndex) == ';') { + try { + int colorToReset = Integer.parseInt(textParameter.substring(lastIndex, charIndex)); + mColors.reset(colorToReset); + mSession.onColorsChanged(); + if (endOfInput) break; + charIndex++; + lastIndex = charIndex; + } catch (NumberFormatException e) { + // Ignore. + } + } + } + } + break; + case 110: // Reset foreground color. + case 111: // Reset background color. + case 112: // Reset cursor color. + mColors.reset(TextStyle.COLOR_INDEX_FOREGROUND + (value - 110)); + mSession.onColorsChanged(); + break; + case 119: // Reset highlight color. + break; + default: + unknownParameter(value); + break; + } + finishSequence(); + } + + private void blockClear(int sx, int sy, int w) { + blockClear(sx, sy, w, 1); + } + + private void blockClear(int sx, int sy, int w, int h) { + mScreen.blockSet(sx, sy, w, h, ' ', getStyle()); + } + + private long getStyle() { + return TextStyle.encode(mForeColor, mBackColor, mEffect); + } + + /** "CSI P_m h" for set or "CSI P_m l" for reset ANSI mode. */ + private void doSetMode(boolean newValue) { + int modeBit = getArg0(0); + switch (modeBit) { + case 4: // Set="Insert Mode". Reset="Replace Mode". (IRM). + mInsertMode = newValue; + break; + case 20: // Normal Linefeed (LNM). + unknownParameter(modeBit); + // http://www.vt100.net/docs/vt510-rm/LNM + break; + case 34: + // Normal cursor visibility - when using TERM=screen, see + // http://www.gnu.org/software/screen/manual/html_node/Control-Sequences.html + break; + default: + unknownParameter(modeBit); + break; + } + } + + /** + * NOTE: The parameters of this function respect the {@link #DECSET_BIT_ORIGIN_MODE}. Use + * {@link #setCursorRowCol(int, int)} for absolute pos. + */ + private void setCursorPosition(int x, int y) { + boolean originMode = isDecsetInternalBitSet(DECSET_BIT_ORIGIN_MODE); + int effectiveTopMargin = originMode ? mTopMargin : 0; + int effectiveBottomMargin = originMode ? mBottomMargin : mRows; + int effectiveLeftMargin = originMode ? mLeftMargin : 0; + int effectiveRightMargin = originMode ? mRightMargin : mColumns; + int newRow = Math.max(effectiveTopMargin, Math.min(effectiveTopMargin + y, effectiveBottomMargin - 1)); + int newCol = Math.max(effectiveLeftMargin, Math.min(effectiveLeftMargin + x, effectiveRightMargin - 1)); + setCursorRowCol(newRow, newCol); + } + + private void scrollDownOneLine() { + mScrollCounter++; + long currentStyle = getStyle(); + if (mLeftMargin != 0 || mRightMargin != mColumns) { + // Horizontal margin: Do not put anything into scroll history, just non-margin part of screen up. + mScreen.blockCopy(mLeftMargin, mTopMargin + 1, mRightMargin - mLeftMargin, mBottomMargin - mTopMargin - 1, mLeftMargin, mTopMargin); + // .. and blank bottom row between margins: + mScreen.blockSet(mLeftMargin, mBottomMargin - 1, mRightMargin - mLeftMargin, 1, ' ', currentStyle); + } else { + mScreen.scrollDownOneLine(mTopMargin, mBottomMargin, currentStyle); + } + } + + /** + * Process the next ASCII character of a parameter. + * + *

You must use the ; character to separate parameters and : to separate sub-parameters. + * + *

Parameter characters modify the action or interpretation of the sequence. Originally + * you can use up to 16 parameters per sequence, but following at least xterm and alacritty + * we use a common space for parameters and sub-parameters, allowing 32 in total. + * + *

All parameters are unsigned, positive decimal integers, with the most significant + * digit sent first. Any parameter greater than 9999 (decimal) is set to 9999 + * (decimal). If you do not specify a value, a 0 value is assumed. A 0 value + * or omitted parameter indicates a default value for the sequence. For most + * sequences, the default value is 1. + * + *

References: + * VT510 Video Terminal Programmer Information: Control Sequences + * alacritty/vte: Implement colon separated CSI parameters + * */ + private void parseArg(int b) { + if (b >= '0' && b <= '9') { + if (mArgIndex < mArgs.length) { + int oldValue = mArgs[mArgIndex]; + int thisDigit = b - '0'; + int value; + if (oldValue >= 0) { + value = oldValue * 10 + thisDigit; + } else { + value = thisDigit; + } + if (value > 9999) + value = 9999; + mArgs[mArgIndex] = value; + } + continueSequence(mEscapeState); + } else if (b == ';' || b == ':') { + if (mArgIndex + 1 < mArgs.length) { + mArgIndex++; + if (b == ':') { + mArgsSubParamsBitSet |= 1 << mArgIndex; + } + } else { + logError("Too many parameters when in state: " + mEscapeState); + } + continueSequence(mEscapeState); + } else { + unknownSequence(b); + } + } + + private int getArg0(int defaultValue) { + return getArg(0, defaultValue, true); + } + + private int getArg1(int defaultValue) { + return getArg(1, defaultValue, true); + } + + private int getArg(int index, int defaultValue, boolean treatZeroAsDefault) { + int result = mArgs[index]; + if (result < 0 || (result == 0 && treatZeroAsDefault)) { + result = defaultValue; + } + return result; + } + + private void collectOSCArgs(int b) { + if (mOSCOrDeviceControlArgs.length() < MAX_OSC_STRING_LENGTH) { + mOSCOrDeviceControlArgs.appendCodePoint(b); + continueSequence(mEscapeState); + } else { + unknownSequence(b); + } + } + + private void unimplementedSequence(int b) { + logError("Unimplemented sequence char '" + (char) b + "' (U+" + String.format("%04x", b) + ")"); + finishSequence(); + } + + private void unknownSequence(int b) { + logError("Unknown sequence char '" + (char) b + "' (numeric value=" + b + ")"); + finishSequence(); + } + + private void unknownParameter(int parameter) { + logError("Unknown parameter: " + parameter); + finishSequence(); + } + + private void logError(String errorType) { + if (LOG_ESCAPE_SEQUENCES) { + StringBuilder buf = new StringBuilder(); + buf.append(errorType); + buf.append(", escapeState="); + buf.append(mEscapeState); + boolean firstArg = true; + if (mArgIndex >= mArgs.length) mArgIndex = mArgs.length - 1; + for (int i = 0; i <= mArgIndex; i++) { + int value = mArgs[i]; + if (value >= 0) { + if (firstArg) { + firstArg = false; + buf.append(", args={"); + } else { + buf.append(','); + } + buf.append(value); + } + } + if (!firstArg) buf.append('}'); + finishSequenceAndLogError(buf.toString()); + } + } + + private void finishSequenceAndLogError(String error) { + if (LOG_ESCAPE_SEQUENCES) Logger.logWarn(mClient, LOG_TAG, error); + finishSequence(); + } + + private void finishSequence() { + mEscapeState = ESC_NONE; + } + + /** + * Send a Unicode code point to the screen. + * + * @param codePoint The code point of the character to display + */ + private void emitCodePoint(int codePoint) { + mLastEmittedCodePoint = codePoint; + if (mUseLineDrawingUsesG0 ? mUseLineDrawingG0 : mUseLineDrawingG1) { + // http://www.vt100.net/docs/vt102-ug/table5-15.html. + switch (codePoint) { + case '_': + codePoint = ' '; // Blank. + break; + case '`': + codePoint = '◆'; // Diamond. + break; + case '0': + codePoint = '█'; // Solid block; + break; + case 'a': + codePoint = '▒'; // Checker board. + break; + case 'b': + codePoint = '␉'; // Horizontal tab. + break; + case 'c': + codePoint = '␌'; // Form feed. + break; + case 'd': + codePoint = '\r'; // Carriage return. + break; + case 'e': + codePoint = '␊'; // Linefeed. + break; + case 'f': + codePoint = '°'; // Degree. + break; + case 'g': + codePoint = '±'; // Plus-minus. + break; + case 'h': + codePoint = '\n'; // Newline. + break; + case 'i': + codePoint = '␋'; // Vertical tab. + break; + case 'j': + codePoint = '┘'; // Lower right corner. + break; + case 'k': + codePoint = '┐'; // Upper right corner. + break; + case 'l': + codePoint = '┌'; // Upper left corner. + break; + case 'm': + codePoint = '└'; // Left left corner. + break; + case 'n': + codePoint = '┼'; // Crossing lines. + break; + case 'o': + codePoint = '⎺'; // Horizontal line - scan 1. + break; + case 'p': + codePoint = '⎻'; // Horizontal line - scan 3. + break; + case 'q': + codePoint = '─'; // Horizontal line - scan 5. + break; + case 'r': + codePoint = '⎼'; // Horizontal line - scan 7. + break; + case 's': + codePoint = '⎽'; // Horizontal line - scan 9. + break; + case 't': + codePoint = '├'; // T facing rightwards. + break; + case 'u': + codePoint = '┤'; // T facing leftwards. + break; + case 'v': + codePoint = '┴'; // T facing upwards. + break; + case 'w': + codePoint = '┬'; // T facing downwards. + break; + case 'x': + codePoint = '│'; // Vertical line. + break; + case 'y': + codePoint = '≤'; // Less than or equal to. + break; + case 'z': + codePoint = '≥'; // Greater than or equal to. + break; + case '{': + codePoint = 'π'; // Pi. + break; + case '|': + codePoint = '≠'; // Not equal to. + break; + case '}': + codePoint = '£'; // UK pound. + break; + case '~': + codePoint = '·'; // Centered dot. + break; + } + } + + final boolean autoWrap = isDecsetInternalBitSet(DECSET_BIT_AUTOWRAP); + final int displayWidth = WcWidth.width(codePoint); + final boolean cursorInLastColumn = mCursorCol == mRightMargin - 1; + + if (autoWrap) { + if (cursorInLastColumn && ((mAboutToAutoWrap && displayWidth == 1) || displayWidth == 2)) { + mScreen.setLineWrap(mCursorRow); + mCursorCol = mLeftMargin; + if (mCursorRow + 1 < mBottomMargin) { + mCursorRow++; + } else { + scrollDownOneLine(); + } + } + } else if (cursorInLastColumn && displayWidth == 2) { + // The behaviour when a wide character is output with cursor in the last column when + // autowrap is disabled is not obvious - it's ignored here. + return; + } + + if (mInsertMode && displayWidth > 0) { + // Move character to right one space. + int destCol = mCursorCol + displayWidth; + if (destCol < mRightMargin) + mScreen.blockCopy(mCursorCol, mCursorRow, mRightMargin - destCol, 1, destCol, mCursorRow); + } + + int offsetDueToCombiningChar = ((displayWidth <= 0 && mCursorCol > 0 && !mAboutToAutoWrap) ? 1 : 0); + int column = mCursorCol - offsetDueToCombiningChar; + + // Fix TerminalRow.setChar() ArrayIndexOutOfBoundsException index=-1 exception reported + // The offsetDueToCombiningChar would never be 1 if mCursorCol was 0 to get column/index=-1, + // so was mCursorCol changed after the offsetDueToCombiningChar conditional by another thread? + // TODO: Check if there are thread synchronization issues with mCursorCol and mCursorRow, possibly causing others bugs too. + if (column < 0) column = 0; + mScreen.setChar(column, mCursorRow, codePoint, getStyle()); + + if (autoWrap && displayWidth > 0) + mAboutToAutoWrap = (mCursorCol == mRightMargin - displayWidth); + + mCursorCol = Math.min(mCursorCol + displayWidth, mRightMargin - 1); + } + + private void setCursorRow(int row) { + mCursorRow = row; + mAboutToAutoWrap = false; + } + + private void setCursorCol(int col) { + mCursorCol = col; + mAboutToAutoWrap = false; + } + + /** Set the cursor mode, but limit it to margins if {@link #DECSET_BIT_ORIGIN_MODE} is enabled. */ + private void setCursorColRespectingOriginMode(int col) { + setCursorPosition(col, mCursorRow); + } + + /** TODO: Better name, distinguished from {@link #setCursorPosition(int, int)} by not regarding origin mode. */ + private void setCursorRowCol(int row, int col) { + mCursorRow = Math.max(0, Math.min(row, mRows - 1)); + mCursorCol = Math.max(0, Math.min(col, mColumns - 1)); + mAboutToAutoWrap = false; + } + + public int getScrollCounter() { + return mScrollCounter; + } + + public void clearScrollCounter() { + mScrollCounter = 0; + } + + /** Reset terminal state so user can interact with it regardless of present state. */ + public void reset() { + setCursorStyle(); + mArgIndex = 0; + mContinueSequence = false; + mEscapeState = ESC_NONE; + mInsertMode = false; + mTopMargin = mLeftMargin = 0; + mBottomMargin = mRows; + mRightMargin = mColumns; + mAboutToAutoWrap = false; + mForeColor = mSavedStateMain.mSavedForeColor = mSavedStateAlt.mSavedForeColor = TextStyle.COLOR_INDEX_FOREGROUND; + mBackColor = mSavedStateMain.mSavedBackColor = mSavedStateAlt.mSavedBackColor = TextStyle.COLOR_INDEX_BACKGROUND; + setDefaultTabStops(); + + mUseLineDrawingG0 = mUseLineDrawingG1 = false; + mUseLineDrawingUsesG0 = true; + + mSavedStateMain.mSavedCursorRow = mSavedStateMain.mSavedCursorCol = mSavedStateMain.mSavedEffect = mSavedStateMain.mSavedDecFlags = 0; + mSavedStateAlt.mSavedCursorRow = mSavedStateAlt.mSavedCursorCol = mSavedStateAlt.mSavedEffect = mSavedStateAlt.mSavedDecFlags = 0; + mCurrentDecSetFlags = 0; + mKittyDepthMain = mKittyDepthAlt = 0; + mModifyOtherKeys = 0; + // Initial wrap-around is not accurate but makes terminal more useful, especially on a small screen: + setDecsetinternalBit(DECSET_BIT_AUTOWRAP, true); + setDecsetinternalBit(DECSET_BIT_CURSOR_ENABLED, true); + mSavedDecSetFlags = mSavedStateMain.mSavedDecFlags = mSavedStateAlt.mSavedDecFlags = mCurrentDecSetFlags; + + // XXX: Should we set terminal driver back to IUTF8 with termios? + mUtf8Index = mUtf8ToFollow = 0; + + mColors.reset(); + mSession.onColorsChanged(); + } + + public String getSelectedText(int x1, int y1, int x2, int y2) { + return mScreen.getSelectedText(x1, y1, x2, y2); + } + + /** Get the terminal session's title (null if not set). */ + public String getTitle() { + return mTitle; + } + + /** Change the terminal session's title. */ + private void setTitle(String newTitle) { + String oldTitle = mTitle; + mTitle = newTitle; + if (!Objects.equals(oldTitle, newTitle)) { + mSession.titleChanged(oldTitle, newTitle); + } + } + + /** If DECSET 2004 is set, prefix paste with "\033[200~" and suffix with "\033[201~". */ + public void paste(String text) { + // First: Always remove escape key and C1 control characters [0x80,0x9F]: + text = text.replaceAll("(\u001B|[\u0080-\u009F])", ""); + // Second: Replace all newlines (\n) or CRLF (\r\n) with carriage returns (\r). + text = text.replaceAll("\r?\n", "\r"); + + // Then: Implement bracketed paste mode if enabled: + boolean bracketed = isDecsetInternalBitSet(DECSET_BIT_BRACKETED_PASTE_MODE); + if (bracketed) mSession.write("\033[200~"); + mSession.write(text); + if (bracketed) mSession.write("\033[201~"); + } + + /** http://www.vt100.net/docs/vt510-rm/DECSC */ + static final class SavedScreenState { + /** Saved state of the cursor position, Used to implement the save/restore cursor position escape sequences. */ + int mSavedCursorRow, mSavedCursorCol; + int mSavedEffect, mSavedForeColor, mSavedBackColor; + int mSavedDecFlags; + boolean mUseLineDrawingG0, mUseLineDrawingG1, mUseLineDrawingUsesG0 = true; + } + + @Override + public String toString() { + return "TerminalEmulator[size=" + mScreen.mColumns + "x" + mScreen.mScreenRows + ", margins={" + mTopMargin + "," + mRightMargin + "," + mBottomMargin + + "," + mLeftMargin + "}]"; + } + +} diff --git a/app/src/main/java/com/termux/terminal/TerminalOutput.java b/app/src/main/java/com/termux/terminal/TerminalOutput.java new file mode 100644 index 0000000..305082a --- /dev/null +++ b/app/src/main/java/com/termux/terminal/TerminalOutput.java @@ -0,0 +1,32 @@ +package com.termux.terminal; + +import java.nio.charset.StandardCharsets; + +/** A client which receives callbacks from events triggered by feeding input to a {@link TerminalEmulator}. */ +public abstract class TerminalOutput { + + /** Write a string using the UTF-8 encoding to the terminal client. */ + public final void write(String data) { + if (data == null) return; + byte[] bytes = data.getBytes(StandardCharsets.UTF_8); + write(bytes, 0, bytes.length); + } + + /** Write bytes to the terminal client. */ + public abstract void write(byte[] data, int offset, int count); + + /** Notify the terminal client that the terminal title has changed. */ + public abstract void titleChanged(String oldTitle, String newTitle); + + /** Notify the terminal client that text should be copied to clipboard. */ + public abstract void onCopyTextToClipboard(String text); + + /** Notify the terminal client that text should be pasted from clipboard. */ + public abstract void onPasteTextFromClipboard(); + + /** Notify the terminal client that a bell character (ASCII 7, bell, BEL, \a, ^G)) has been received. */ + public abstract void onBell(); + + public abstract void onColorsChanged(); + +} diff --git a/app/src/main/java/com/termux/terminal/TerminalRow.java b/app/src/main/java/com/termux/terminal/TerminalRow.java new file mode 100644 index 0000000..d68dc32 --- /dev/null +++ b/app/src/main/java/com/termux/terminal/TerminalRow.java @@ -0,0 +1,283 @@ +package com.termux.terminal; + +import java.util.Arrays; + +/** + * A row in a terminal, composed of a fixed number of cells. + *

+ * The text in the row is stored in a char[] array, {@link #mText}, for quick access during rendering. + */ +public final class TerminalRow { + + private static final float SPARE_CAPACITY_FACTOR = 1.5f; + + /** + * Max combining characters that can exist in a column, that are separate from the base character + * itself. Any additional combining characters will be ignored and not added to the column. + * + * There does not seem to be limit in unicode standard for max number of combination characters + * that can be combined but such characters are primarily under 10. + * + * "Section 3.6 Combination" of unicode standard contains combining characters info. + * - https://www.unicode.org/versions/Unicode15.0.0/ch03.pdf + * - https://en.wikipedia.org/wiki/Combining_character#Unicode_ranges + * - https://stackoverflow.com/questions/71237212/what-is-the-maximum-number-of-unicode-combined-characters-that-may-be-needed-to + * + * UAX15-D3 Stream-Safe Text Format limits to max 30 combining characters. + * > The value of 30 is chosen to be significantly beyond what is required for any linguistic or technical usage. + * > While it would have been feasible to chose a smaller number, this value provides a very wide margin, + * > yet is well within the buffer size limits of practical implementations. + * - https://unicode.org/reports/tr15/#Stream_Safe_Text_Format + * - https://stackoverflow.com/a/11983435/14686958 + * + * We choose the value 15 because it should be enough for terminal based applications and keep + * the memory usage low for a terminal row, won't affect performance or cause terminal to + * lag or hang, and will keep malicious applications from causing harm. The value can be + * increased if ever needed for legitimate applications. + */ + private static final int MAX_COMBINING_CHARACTERS_PER_COLUMN = 15; + + /** The number of columns in this terminal row. */ + private final int mColumns; + /** The text filling this terminal row. */ + public char[] mText; + /** The number of java chars used in {@link #mText}. */ + private short mSpaceUsed; + /** If this row has been line wrapped due to text output at the end of line. */ + boolean mLineWrap; + /** The style bits of each cell in the row. See {@link TextStyle}. */ + final long[] mStyle; + /** If this row might contain chars with width != 1, used for deactivating fast path */ + boolean mHasNonOneWidthOrSurrogateChars; + + /** Construct a blank row (containing only whitespace, ' ') with a specified style. */ + public TerminalRow(int columns, long style) { + mColumns = columns; + mText = new char[(int) (SPARE_CAPACITY_FACTOR * columns)]; + mStyle = new long[columns]; + clear(style); + } + + /** NOTE: The sourceX2 is exclusive. */ + public void copyInterval(TerminalRow line, int sourceX1, int sourceX2, int destinationX) { + mHasNonOneWidthOrSurrogateChars |= line.mHasNonOneWidthOrSurrogateChars; + final int x1 = line.findStartOfColumn(sourceX1); + final int x2 = line.findStartOfColumn(sourceX2); + boolean startingFromSecondHalfOfWideChar = (sourceX1 > 0 && line.wideDisplayCharacterStartingAt(sourceX1 - 1)); + final char[] sourceChars = (this == line) ? Arrays.copyOf(line.mText, line.mText.length) : line.mText; + int latestNonCombiningWidth = 0; + for (int i = x1; i < x2; i++) { + char sourceChar = sourceChars[i]; + int codePoint = Character.isHighSurrogate(sourceChar) ? Character.toCodePoint(sourceChar, sourceChars[++i]) : sourceChar; + if (startingFromSecondHalfOfWideChar) { + // Just treat copying second half of wide char as copying whitespace. + codePoint = ' '; + startingFromSecondHalfOfWideChar = false; + } + int w = WcWidth.width(codePoint); + if (w > 0) { + destinationX += latestNonCombiningWidth; + sourceX1 += latestNonCombiningWidth; + latestNonCombiningWidth = w; + } + setChar(destinationX, codePoint, line.getStyle(sourceX1)); + } + } + + public int getSpaceUsed() { + return mSpaceUsed; + } + + /** Note that the column may end of second half of wide character. */ + public int findStartOfColumn(int column) { + if (column == mColumns) return getSpaceUsed(); + + int currentColumn = 0; + int currentCharIndex = 0; + while (true) { // 0<2 1 < 2 + int newCharIndex = currentCharIndex; + char c = mText[newCharIndex++]; // cci=1, cci=2 + boolean isHigh = Character.isHighSurrogate(c); + int codePoint = isHigh ? Character.toCodePoint(c, mText[newCharIndex++]) : c; + int wcwidth = WcWidth.width(codePoint); // 1, 2 + if (wcwidth > 0) { + currentColumn += wcwidth; + if (currentColumn == column) { + while (newCharIndex < mSpaceUsed) { + // Skip combining chars. + if (Character.isHighSurrogate(mText[newCharIndex])) { + if (WcWidth.width(Character.toCodePoint(mText[newCharIndex], mText[newCharIndex + 1])) <= 0) { + newCharIndex += 2; + } else { + break; + } + } else if (WcWidth.width(mText[newCharIndex]) <= 0) { + newCharIndex++; + } else { + break; + } + } + return newCharIndex; + } else if (currentColumn > column) { + // Wide column going past end. + return currentCharIndex; + } + } + currentCharIndex = newCharIndex; + } + } + + private boolean wideDisplayCharacterStartingAt(int column) { + for (int currentCharIndex = 0, currentColumn = 0; currentCharIndex < mSpaceUsed; ) { + char c = mText[currentCharIndex++]; + int codePoint = Character.isHighSurrogate(c) ? Character.toCodePoint(c, mText[currentCharIndex++]) : c; + int wcwidth = WcWidth.width(codePoint); + if (wcwidth > 0) { + if (currentColumn == column && wcwidth == 2) return true; + currentColumn += wcwidth; + if (currentColumn > column) return false; + } + } + return false; + } + + public void clear(long style) { + Arrays.fill(mText, ' '); + Arrays.fill(mStyle, style); + mSpaceUsed = (short) mColumns; + mHasNonOneWidthOrSurrogateChars = false; + } + + // https://github.com/steven676/Android-Terminal-Emulator/commit/9a47042620bec87617f0b4f5d50568535668fe26 + public void setChar(int columnToSet, int codePoint, long style) { + if (columnToSet < 0 || columnToSet >= mStyle.length) + throw new IllegalArgumentException("TerminalRow.setChar(): columnToSet=" + columnToSet + ", codePoint=" + codePoint + ", style=" + style); + + mStyle[columnToSet] = style; + + final int newCodePointDisplayWidth = WcWidth.width(codePoint); + + // Fast path when we don't have any chars with width != 1 + if (!mHasNonOneWidthOrSurrogateChars) { + if (codePoint >= Character.MIN_SUPPLEMENTARY_CODE_POINT || newCodePointDisplayWidth != 1) { + mHasNonOneWidthOrSurrogateChars = true; + } else { + mText[columnToSet] = (char) codePoint; + return; + } + } + + final boolean newIsCombining = newCodePointDisplayWidth <= 0; + + boolean wasExtraColForWideChar = (columnToSet > 0) && wideDisplayCharacterStartingAt(columnToSet - 1); + + if (newIsCombining) { + // When standing at second half of wide character and inserting combining: + if (wasExtraColForWideChar) columnToSet--; + } else { + // Check if we are overwriting the second half of a wide character starting at the previous column: + if (wasExtraColForWideChar) setChar(columnToSet - 1, ' ', style); + // Check if we are overwriting the first half of a wide character starting at the next column: + boolean overwritingWideCharInNextColumn = newCodePointDisplayWidth == 2 && wideDisplayCharacterStartingAt(columnToSet + 1); + if (overwritingWideCharInNextColumn) setChar(columnToSet + 1, ' ', style); + } + + char[] text = mText; + final int oldStartOfColumnIndex = findStartOfColumn(columnToSet); + final int oldCodePointDisplayWidth = WcWidth.width(text, oldStartOfColumnIndex); + + // Get the number of elements in the mText array this column uses now + int oldCharactersUsedForColumn; + if (columnToSet + oldCodePointDisplayWidth < mColumns) { + int oldEndOfColumnIndex = findStartOfColumn(columnToSet + oldCodePointDisplayWidth); + oldCharactersUsedForColumn = oldEndOfColumnIndex - oldStartOfColumnIndex; + } else { + // Last character. + oldCharactersUsedForColumn = mSpaceUsed - oldStartOfColumnIndex; + } + + // If MAX_COMBINING_CHARACTERS_PER_COLUMN already exist in column, then ignore adding additional combining characters. + if (newIsCombining) { + int combiningCharsCount = WcWidth.zeroWidthCharsCount(mText, oldStartOfColumnIndex, oldStartOfColumnIndex + oldCharactersUsedForColumn); + if (combiningCharsCount >= MAX_COMBINING_CHARACTERS_PER_COLUMN) + return; + } + + // Find how many chars this column will need + int newCharactersUsedForColumn = Character.charCount(codePoint); + if (newIsCombining) { + // Combining characters are added to the contents of the column instead of overwriting them, so that they + // modify the existing contents. + // FIXME: Unassigned characters also get width=0. + newCharactersUsedForColumn += oldCharactersUsedForColumn; + } + + int oldNextColumnIndex = oldStartOfColumnIndex + oldCharactersUsedForColumn; + int newNextColumnIndex = oldStartOfColumnIndex + newCharactersUsedForColumn; + + final int javaCharDifference = newCharactersUsedForColumn - oldCharactersUsedForColumn; + if (javaCharDifference > 0) { + // Shift the rest of the line right. + int oldCharactersAfterColumn = mSpaceUsed - oldNextColumnIndex; + if (mSpaceUsed + javaCharDifference > text.length) { + // We need to grow the array + char[] newText = new char[text.length + mColumns]; + System.arraycopy(text, 0, newText, 0, oldNextColumnIndex); + System.arraycopy(text, oldNextColumnIndex, newText, newNextColumnIndex, oldCharactersAfterColumn); + mText = text = newText; + } else { + System.arraycopy(text, oldNextColumnIndex, text, newNextColumnIndex, oldCharactersAfterColumn); + } + } else if (javaCharDifference < 0) { + // Shift the rest of the line left. + System.arraycopy(text, oldNextColumnIndex, text, newNextColumnIndex, mSpaceUsed - oldNextColumnIndex); + } + mSpaceUsed += javaCharDifference; + + // Store char. A combining character is stored at the end of the existing contents so that it modifies them: + //noinspection ResultOfMethodCallIgnored - since we already now how many java chars is used. + Character.toChars(codePoint, text, oldStartOfColumnIndex + (newIsCombining ? oldCharactersUsedForColumn : 0)); + + if (oldCodePointDisplayWidth == 2 && newCodePointDisplayWidth == 1) { + // Replace second half of wide char with a space. Which mean that we actually add a ' ' java character. + if (mSpaceUsed + 1 > text.length) { + char[] newText = new char[text.length + mColumns]; + System.arraycopy(text, 0, newText, 0, newNextColumnIndex); + System.arraycopy(text, newNextColumnIndex, newText, newNextColumnIndex + 1, mSpaceUsed - newNextColumnIndex); + mText = text = newText; + } else { + System.arraycopy(text, newNextColumnIndex, text, newNextColumnIndex + 1, mSpaceUsed - newNextColumnIndex); + } + text[newNextColumnIndex] = ' '; + + ++mSpaceUsed; + } else if (oldCodePointDisplayWidth == 1 && newCodePointDisplayWidth == 2) { + if (columnToSet == mColumns - 1) { + 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; + } 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. + int newNextNextColumnIndex = newNextColumnIndex + (Character.isHighSurrogate(mText[newNextColumnIndex]) ? 2 : 1); + int nextLen = newNextNextColumnIndex - newNextColumnIndex; + + // Shift the array leftwards. + System.arraycopy(text, newNextNextColumnIndex, text, newNextColumnIndex, mSpaceUsed - newNextNextColumnIndex); + mSpaceUsed -= nextLen; + } + } + } + + boolean isBlank() { + for (int charIndex = 0, charLen = getSpaceUsed(); charIndex < charLen; charIndex++) + if (mText[charIndex] != ' ') return false; + return true; + } + + public final long getStyle(int column) { + return mStyle[column]; + } + +} diff --git a/app/src/main/java/com/termux/terminal/TerminalSessionClient.java b/app/src/main/java/com/termux/terminal/TerminalSessionClient.java new file mode 100644 index 0000000..cab97c4 --- /dev/null +++ b/app/src/main/java/com/termux/terminal/TerminalSessionClient.java @@ -0,0 +1,29 @@ +package com.termux.terminal; + +/** + * Callbacks from {@link TerminalEmulator} to its host. + * + * Modified for whc-shell: the TerminalSession-specific callbacks were removed because the + * emulator here is driven by an SSH channel instead of a local process. + */ +public interface TerminalSessionClient { + + void onTerminalCursorStateChange(boolean state); + + Integer getTerminalCursorStyle(); + + void logError(String tag, String message); + + void logWarn(String tag, String message); + + void logInfo(String tag, String message); + + void logDebug(String tag, String message); + + void logVerbose(String tag, String message); + + void logStackTraceWithMessage(String tag, String message, Exception e); + + void logStackTrace(String tag, Exception e); + +} diff --git a/app/src/main/java/com/termux/terminal/TextStyle.java b/app/src/main/java/com/termux/terminal/TextStyle.java new file mode 100644 index 0000000..173d6ae --- /dev/null +++ b/app/src/main/java/com/termux/terminal/TextStyle.java @@ -0,0 +1,90 @@ +package com.termux.terminal; + +/** + *

+ * Encodes effects, foreground and background colors into a 64 bit long, which are stored for each cell in a terminal + * row in {@link TerminalRow#mStyle}. + *

+ *

+ * The bit layout is: + *

+ * - 16 flags (11 currently used). + * - 24 for foreground color (only 9 first bits if a color index). + * - 24 for background color (only 9 first bits if a color index). + */ +public final class TextStyle { + + public final static int CHARACTER_ATTRIBUTE_BOLD = 1; + public final static int CHARACTER_ATTRIBUTE_ITALIC = 1 << 1; + public final static int CHARACTER_ATTRIBUTE_UNDERLINE = 1 << 2; + public final static int CHARACTER_ATTRIBUTE_BLINK = 1 << 3; + public final static int CHARACTER_ATTRIBUTE_INVERSE = 1 << 4; + public final static int CHARACTER_ATTRIBUTE_INVISIBLE = 1 << 5; + public final static int CHARACTER_ATTRIBUTE_STRIKETHROUGH = 1 << 6; + /** + * The selective erase control functions (DECSED and DECSEL) can only erase characters defined as erasable. + *

+ * This bit is set if DECSCA (Select Character Protection Attribute) has been used to define the characters that + * come after it as erasable from the screen. + *

+ */ + public final static int CHARACTER_ATTRIBUTE_PROTECTED = 1 << 7; + /** Dim colors. Also known as faint or half intensity. */ + public final static int CHARACTER_ATTRIBUTE_DIM = 1 << 8; + /** If true (24-bit) color is used for the cell for foreground. */ + 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; + + public final static int COLOR_INDEX_FOREGROUND = 256; + public final static int COLOR_INDEX_BACKGROUND = 257; + public final static int COLOR_INDEX_CURSOR = 258; + + /** The 256 standard color entries and the three special (foreground, background and cursor) ones. */ + public final static int NUM_INDEXED_COLORS = 259; + + /** Normal foreground and background colors and no effects. */ + final static long NORMAL = encode(COLOR_INDEX_FOREGROUND, COLOR_INDEX_BACKGROUND, 0); + + static long encode(int foreColor, int backColor, int effect) { + long result = effect & 0b111111111; + if ((0xff000000 & foreColor) == 0xff000000) { + // 24-bit color. + result |= CHARACTER_ATTRIBUTE_TRUECOLOR_FOREGROUND | ((foreColor & 0x00ffffffL) << 40L); + } else { + // Indexed color. + result |= (foreColor & 0b111111111L) << 40; + } + if ((0xff000000 & backColor) == 0xff000000) { + // 24-bit color. + result |= CHARACTER_ATTRIBUTE_TRUECOLOR_BACKGROUND | ((backColor & 0x00ffffffL) << 16L); + } else { + // Indexed color. + result |= (backColor & 0b111111111L) << 16L; + } + + return result; + } + + public static int decodeForeColor(long style) { + if ((style & CHARACTER_ATTRIBUTE_TRUECOLOR_FOREGROUND) == 0) { + return (int) ((style >>> 40) & 0b111111111L); + } else { + return 0xff000000 | (int) ((style >>> 40) & 0x00ffffffL); + } + + } + + public static int decodeBackColor(long style) { + if ((style & CHARACTER_ATTRIBUTE_TRUECOLOR_BACKGROUND) == 0) { + return (int) ((style >>> 16) & 0b111111111L); + } else { + return 0xff000000 | (int) ((style >>> 16) & 0x00ffffffL); + } + } + + public static int decodeEffect(long style) { + return (int) (style & 0b11111111111); + } + +} diff --git a/app/src/main/java/com/termux/terminal/WcWidth.java b/app/src/main/java/com/termux/terminal/WcWidth.java new file mode 100644 index 0000000..d71cc27 --- /dev/null +++ b/app/src/main/java/com/termux/terminal/WcWidth.java @@ -0,0 +1,566 @@ +package com.termux.terminal; + +/** + * Implementation of wcwidth(3) for Unicode 15. + * + * Implementation from https://github.com/jquast/wcwidth but we return 0 for unprintable characters. + * + * IMPORTANT: + * Must be kept in sync with the following: + * https://github.com/termux/wcwidth + * https://github.com/termux/libandroid-support + * https://github.com/termux/termux-packages/tree/master/packages/libandroid-support + */ +public final class WcWidth { + + // From https://github.com/jquast/wcwidth/blob/master/wcwidth/table_zero.py + // from https://github.com/jquast/wcwidth/pull/64 + // at commit 1b9b6585b0080ea5cb88dc9815796505724793fe (2022-12-16): + private static final int[][] ZERO_WIDTH = { + {0x00300, 0x0036f}, // Combining Grave Accent ..Combining Latin Small Le + {0x00483, 0x00489}, // Combining Cyrillic Titlo..Combining Cyrillic Milli + {0x00591, 0x005bd}, // Hebrew Accent Etnahta ..Hebrew Point Meteg + {0x005bf, 0x005bf}, // Hebrew Point Rafe ..Hebrew Point Rafe + {0x005c1, 0x005c2}, // Hebrew Point Shin Dot ..Hebrew Point Sin Dot + {0x005c4, 0x005c5}, // Hebrew Mark Upper Dot ..Hebrew Mark Lower Dot + {0x005c7, 0x005c7}, // Hebrew Point Qamats Qata..Hebrew Point Qamats Qata + {0x00610, 0x0061a}, // Arabic Sign Sallallahou ..Arabic Small Kasra + {0x0064b, 0x0065f}, // Arabic Fathatan ..Arabic Wavy Hamza Below + {0x00670, 0x00670}, // Arabic Letter Superscrip..Arabic Letter Superscrip + {0x006d6, 0x006dc}, // Arabic Small High Ligatu..Arabic Small High Seen + {0x006df, 0x006e4}, // Arabic Small High Rounde..Arabic Small High Madda + {0x006e7, 0x006e8}, // Arabic Small High Yeh ..Arabic Small High Noon + {0x006ea, 0x006ed}, // Arabic Empty Centre Low ..Arabic Small Low Meem + {0x00711, 0x00711}, // Syriac Letter Superscrip..Syriac Letter Superscrip + {0x00730, 0x0074a}, // Syriac Pthaha Above ..Syriac Barrekh + {0x007a6, 0x007b0}, // Thaana Abafili ..Thaana Sukun + {0x007eb, 0x007f3}, // Nko Combining Short High..Nko Combining Double Dot + {0x007fd, 0x007fd}, // Nko Dantayalan ..Nko Dantayalan + {0x00816, 0x00819}, // Samaritan Mark In ..Samaritan Mark Dagesh + {0x0081b, 0x00823}, // Samaritan Mark Epentheti..Samaritan Vowel Sign A + {0x00825, 0x00827}, // Samaritan Vowel Sign Sho..Samaritan Vowel Sign U + {0x00829, 0x0082d}, // Samaritan Vowel Sign Lon..Samaritan Mark Nequdaa + {0x00859, 0x0085b}, // Mandaic Affrication Mark..Mandaic Gemination Mark + {0x00898, 0x0089f}, // Arabic Small High Word A..Arabic Half Madda Over M + {0x008ca, 0x008e1}, // Arabic Small High Farsi ..Arabic Small High Sign S + {0x008e3, 0x00902}, // Arabic Turned Damma Belo..Devanagari Sign Anusvara + {0x0093a, 0x0093a}, // Devanagari Vowel Sign Oe..Devanagari Vowel Sign Oe + {0x0093c, 0x0093c}, // Devanagari Sign Nukta ..Devanagari Sign Nukta + {0x00941, 0x00948}, // Devanagari Vowel Sign U ..Devanagari Vowel Sign Ai + {0x0094d, 0x0094d}, // Devanagari Sign Virama ..Devanagari Sign Virama + {0x00951, 0x00957}, // Devanagari Stress Sign U..Devanagari Vowel Sign Uu + {0x00962, 0x00963}, // Devanagari Vowel Sign Vo..Devanagari Vowel Sign Vo + {0x00981, 0x00981}, // Bengali Sign Candrabindu..Bengali Sign Candrabindu + {0x009bc, 0x009bc}, // Bengali Sign Nukta ..Bengali Sign Nukta + {0x009c1, 0x009c4}, // Bengali Vowel Sign U ..Bengali Vowel Sign Vocal + {0x009cd, 0x009cd}, // Bengali Sign Virama ..Bengali Sign Virama + {0x009e2, 0x009e3}, // Bengali Vowel Sign Vocal..Bengali Vowel Sign Vocal + {0x009fe, 0x009fe}, // Bengali Sandhi Mark ..Bengali Sandhi Mark + {0x00a01, 0x00a02}, // Gurmukhi Sign Adak Bindi..Gurmukhi Sign Bindi + {0x00a3c, 0x00a3c}, // Gurmukhi Sign Nukta ..Gurmukhi Sign Nukta + {0x00a41, 0x00a42}, // Gurmukhi Vowel Sign U ..Gurmukhi Vowel Sign Uu + {0x00a47, 0x00a48}, // Gurmukhi Vowel Sign Ee ..Gurmukhi Vowel Sign Ai + {0x00a4b, 0x00a4d}, // Gurmukhi Vowel Sign Oo ..Gurmukhi Sign Virama + {0x00a51, 0x00a51}, // Gurmukhi Sign Udaat ..Gurmukhi Sign Udaat + {0x00a70, 0x00a71}, // Gurmukhi Tippi ..Gurmukhi Addak + {0x00a75, 0x00a75}, // Gurmukhi Sign Yakash ..Gurmukhi Sign Yakash + {0x00a81, 0x00a82}, // Gujarati Sign Candrabind..Gujarati Sign Anusvara + {0x00abc, 0x00abc}, // Gujarati Sign Nukta ..Gujarati Sign Nukta + {0x00ac1, 0x00ac5}, // Gujarati Vowel Sign U ..Gujarati Vowel Sign Cand + {0x00ac7, 0x00ac8}, // Gujarati Vowel Sign E ..Gujarati Vowel Sign Ai + {0x00acd, 0x00acd}, // Gujarati Sign Virama ..Gujarati Sign Virama + {0x00ae2, 0x00ae3}, // Gujarati Vowel Sign Voca..Gujarati Vowel Sign Voca + {0x00afa, 0x00aff}, // Gujarati Sign Sukun ..Gujarati Sign Two-circle + {0x00b01, 0x00b01}, // Oriya Sign Candrabindu ..Oriya Sign Candrabindu + {0x00b3c, 0x00b3c}, // Oriya Sign Nukta ..Oriya Sign Nukta + {0x00b3f, 0x00b3f}, // Oriya Vowel Sign I ..Oriya Vowel Sign I + {0x00b41, 0x00b44}, // Oriya Vowel Sign U ..Oriya Vowel Sign Vocalic + {0x00b4d, 0x00b4d}, // Oriya Sign Virama ..Oriya Sign Virama + {0x00b55, 0x00b56}, // Oriya Sign Overline ..Oriya Ai Length Mark + {0x00b62, 0x00b63}, // Oriya Vowel Sign Vocalic..Oriya Vowel Sign Vocalic + {0x00b82, 0x00b82}, // Tamil Sign Anusvara ..Tamil Sign Anusvara + {0x00bc0, 0x00bc0}, // Tamil Vowel Sign Ii ..Tamil Vowel Sign Ii + {0x00bcd, 0x00bcd}, // Tamil Sign Virama ..Tamil Sign Virama + {0x00c00, 0x00c00}, // Telugu Sign Combining Ca..Telugu Sign Combining Ca + {0x00c04, 0x00c04}, // Telugu Sign Combining An..Telugu Sign Combining An + {0x00c3c, 0x00c3c}, // Telugu Sign Nukta ..Telugu Sign Nukta + {0x00c3e, 0x00c40}, // Telugu Vowel Sign Aa ..Telugu Vowel Sign Ii + {0x00c46, 0x00c48}, // Telugu Vowel Sign E ..Telugu Vowel Sign Ai + {0x00c4a, 0x00c4d}, // Telugu Vowel Sign O ..Telugu Sign Virama + {0x00c55, 0x00c56}, // Telugu Length Mark ..Telugu Ai Length Mark + {0x00c62, 0x00c63}, // Telugu Vowel Sign Vocali..Telugu Vowel Sign Vocali + {0x00c81, 0x00c81}, // Kannada Sign Candrabindu..Kannada Sign Candrabindu + {0x00cbc, 0x00cbc}, // Kannada Sign Nukta ..Kannada Sign Nukta + {0x00cbf, 0x00cbf}, // Kannada Vowel Sign I ..Kannada Vowel Sign I + {0x00cc6, 0x00cc6}, // Kannada Vowel Sign E ..Kannada Vowel Sign E + {0x00ccc, 0x00ccd}, // Kannada Vowel Sign Au ..Kannada Sign Virama + {0x00ce2, 0x00ce3}, // Kannada Vowel Sign Vocal..Kannada Vowel Sign Vocal + {0x00d00, 0x00d01}, // Malayalam Sign Combining..Malayalam Sign Candrabin + {0x00d3b, 0x00d3c}, // Malayalam Sign Vertical ..Malayalam Sign Circular + {0x00d41, 0x00d44}, // Malayalam Vowel Sign U ..Malayalam Vowel Sign Voc + {0x00d4d, 0x00d4d}, // Malayalam Sign Virama ..Malayalam Sign Virama + {0x00d62, 0x00d63}, // Malayalam Vowel Sign Voc..Malayalam Vowel Sign Voc + {0x00d81, 0x00d81}, // Sinhala Sign Candrabindu..Sinhala Sign Candrabindu + {0x00dca, 0x00dca}, // Sinhala Sign Al-lakuna ..Sinhala Sign Al-lakuna + {0x00dd2, 0x00dd4}, // Sinhala Vowel Sign Ketti..Sinhala Vowel Sign Ketti + {0x00dd6, 0x00dd6}, // Sinhala Vowel Sign Diga ..Sinhala Vowel Sign Diga + {0x00e31, 0x00e31}, // Thai Character Mai Han-a..Thai Character Mai Han-a + {0x00e34, 0x00e3a}, // Thai Character Sara I ..Thai Character Phinthu + {0x00e47, 0x00e4e}, // Thai Character Maitaikhu..Thai Character Yamakkan + {0x00eb1, 0x00eb1}, // Lao Vowel Sign Mai Kan ..Lao Vowel Sign Mai Kan + {0x00eb4, 0x00ebc}, // Lao Vowel Sign I ..Lao Semivowel Sign Lo + {0x00ec8, 0x00ece}, // Lao Tone Mai Ek ..(nil) + {0x00f18, 0x00f19}, // Tibetan Astrological Sig..Tibetan Astrological Sig + {0x00f35, 0x00f35}, // Tibetan Mark Ngas Bzung ..Tibetan Mark Ngas Bzung + {0x00f37, 0x00f37}, // Tibetan Mark Ngas Bzung ..Tibetan Mark Ngas Bzung + {0x00f39, 0x00f39}, // Tibetan Mark Tsa -phru ..Tibetan Mark Tsa -phru + {0x00f71, 0x00f7e}, // Tibetan Vowel Sign Aa ..Tibetan Sign Rjes Su Nga + {0x00f80, 0x00f84}, // Tibetan Vowel Sign Rever..Tibetan Mark Halanta + {0x00f86, 0x00f87}, // Tibetan Sign Lci Rtags ..Tibetan Sign Yang Rtags + {0x00f8d, 0x00f97}, // Tibetan Subjoined Sign L..Tibetan Subjoined Letter + {0x00f99, 0x00fbc}, // Tibetan Subjoined Letter..Tibetan Subjoined Letter + {0x00fc6, 0x00fc6}, // Tibetan Symbol Padma Gda..Tibetan Symbol Padma Gda + {0x0102d, 0x01030}, // Myanmar Vowel Sign I ..Myanmar Vowel Sign Uu + {0x01032, 0x01037}, // Myanmar Vowel Sign Ai ..Myanmar Sign Dot Below + {0x01039, 0x0103a}, // Myanmar Sign Virama ..Myanmar Sign Asat + {0x0103d, 0x0103e}, // Myanmar Consonant Sign M..Myanmar Consonant Sign M + {0x01058, 0x01059}, // Myanmar Vowel Sign Vocal..Myanmar Vowel Sign Vocal + {0x0105e, 0x01060}, // Myanmar Consonant Sign M..Myanmar Consonant Sign M + {0x01071, 0x01074}, // Myanmar Vowel Sign Geba ..Myanmar Vowel Sign Kayah + {0x01082, 0x01082}, // Myanmar Consonant Sign S..Myanmar Consonant Sign S + {0x01085, 0x01086}, // Myanmar Vowel Sign Shan ..Myanmar Vowel Sign Shan + {0x0108d, 0x0108d}, // Myanmar Sign Shan Counci..Myanmar Sign Shan Counci + {0x0109d, 0x0109d}, // Myanmar Vowel Sign Aiton..Myanmar Vowel Sign Aiton + {0x0135d, 0x0135f}, // Ethiopic Combining Gemin..Ethiopic Combining Gemin + {0x01712, 0x01714}, // Tagalog Vowel Sign I ..Tagalog Sign Virama + {0x01732, 0x01733}, // Hanunoo Vowel Sign I ..Hanunoo Vowel Sign U + {0x01752, 0x01753}, // Buhid Vowel Sign I ..Buhid Vowel Sign U + {0x01772, 0x01773}, // Tagbanwa Vowel Sign I ..Tagbanwa Vowel Sign U + {0x017b4, 0x017b5}, // Khmer Vowel Inherent Aq ..Khmer Vowel Inherent Aa + {0x017b7, 0x017bd}, // Khmer Vowel Sign I ..Khmer Vowel Sign Ua + {0x017c6, 0x017c6}, // Khmer Sign Nikahit ..Khmer Sign Nikahit + {0x017c9, 0x017d3}, // Khmer Sign Muusikatoan ..Khmer Sign Bathamasat + {0x017dd, 0x017dd}, // Khmer Sign Atthacan ..Khmer Sign Atthacan + {0x0180b, 0x0180d}, // Mongolian Free Variation..Mongolian Free Variation + {0x0180f, 0x0180f}, // Mongolian Free Variation..Mongolian Free Variation + {0x01885, 0x01886}, // Mongolian Letter Ali Gal..Mongolian Letter Ali Gal + {0x018a9, 0x018a9}, // Mongolian Letter Ali Gal..Mongolian Letter Ali Gal + {0x01920, 0x01922}, // Limbu Vowel Sign A ..Limbu Vowel Sign U + {0x01927, 0x01928}, // Limbu Vowel Sign E ..Limbu Vowel Sign O + {0x01932, 0x01932}, // Limbu Small Letter Anusv..Limbu Small Letter Anusv + {0x01939, 0x0193b}, // Limbu Sign Mukphreng ..Limbu Sign Sa-i + {0x01a17, 0x01a18}, // Buginese Vowel Sign I ..Buginese Vowel Sign U + {0x01a1b, 0x01a1b}, // Buginese Vowel Sign Ae ..Buginese Vowel Sign Ae + {0x01a56, 0x01a56}, // Tai Tham Consonant Sign ..Tai Tham Consonant Sign + {0x01a58, 0x01a5e}, // Tai Tham Sign Mai Kang L..Tai Tham Consonant Sign + {0x01a60, 0x01a60}, // Tai Tham Sign Sakot ..Tai Tham Sign Sakot + {0x01a62, 0x01a62}, // Tai Tham Vowel Sign Mai ..Tai Tham Vowel Sign Mai + {0x01a65, 0x01a6c}, // Tai Tham Vowel Sign I ..Tai Tham Vowel Sign Oa B + {0x01a73, 0x01a7c}, // Tai Tham Vowel Sign Oa A..Tai Tham Sign Khuen-lue + {0x01a7f, 0x01a7f}, // Tai Tham Combining Crypt..Tai Tham Combining Crypt + {0x01ab0, 0x01ace}, // Combining Doubled Circum..Combining Latin Small Le + {0x01b00, 0x01b03}, // Balinese Sign Ulu Ricem ..Balinese Sign Surang + {0x01b34, 0x01b34}, // Balinese Sign Rerekan ..Balinese Sign Rerekan + {0x01b36, 0x01b3a}, // Balinese Vowel Sign Ulu ..Balinese Vowel Sign Ra R + {0x01b3c, 0x01b3c}, // Balinese Vowel Sign La L..Balinese Vowel Sign La L + {0x01b42, 0x01b42}, // Balinese Vowel Sign Pepe..Balinese Vowel Sign Pepe + {0x01b6b, 0x01b73}, // Balinese Musical Symbol ..Balinese Musical Symbol + {0x01b80, 0x01b81}, // Sundanese Sign Panyecek ..Sundanese Sign Panglayar + {0x01ba2, 0x01ba5}, // Sundanese Consonant Sign..Sundanese Vowel Sign Pan + {0x01ba8, 0x01ba9}, // Sundanese Vowel Sign Pam..Sundanese Vowel Sign Pan + {0x01bab, 0x01bad}, // Sundanese Sign Virama ..Sundanese Consonant Sign + {0x01be6, 0x01be6}, // Batak Sign Tompi ..Batak Sign Tompi + {0x01be8, 0x01be9}, // Batak Vowel Sign Pakpak ..Batak Vowel Sign Ee + {0x01bed, 0x01bed}, // Batak Vowel Sign Karo O ..Batak Vowel Sign Karo O + {0x01bef, 0x01bf1}, // Batak Vowel Sign U For S..Batak Consonant Sign H + {0x01c2c, 0x01c33}, // Lepcha Vowel Sign E ..Lepcha Consonant Sign T + {0x01c36, 0x01c37}, // Lepcha Sign Ran ..Lepcha Sign Nukta + {0x01cd0, 0x01cd2}, // Vedic Tone Karshana ..Vedic Tone Prenkha + {0x01cd4, 0x01ce0}, // Vedic Sign Yajurvedic Mi..Vedic Tone Rigvedic Kash + {0x01ce2, 0x01ce8}, // Vedic Sign Visarga Svari..Vedic Sign Visarga Anuda + {0x01ced, 0x01ced}, // Vedic Sign Tiryak ..Vedic Sign Tiryak + {0x01cf4, 0x01cf4}, // Vedic Tone Candra Above ..Vedic Tone Candra Above + {0x01cf8, 0x01cf9}, // Vedic Tone Ring Above ..Vedic Tone Double Ring A + {0x01dc0, 0x01dff}, // Combining Dotted Grave A..Combining Right Arrowhea + {0x020d0, 0x020f0}, // Combining Left Harpoon A..Combining Asterisk Above + {0x02cef, 0x02cf1}, // Coptic Combining Ni Abov..Coptic Combining Spiritu + {0x02d7f, 0x02d7f}, // Tifinagh Consonant Joine..Tifinagh Consonant Joine + {0x02de0, 0x02dff}, // Combining Cyrillic Lette..Combining Cyrillic Lette + {0x0302a, 0x0302d}, // Ideographic Level Tone M..Ideographic Entering Ton + {0x03099, 0x0309a}, // Combining Katakana-hirag..Combining Katakana-hirag + {0x0a66f, 0x0a672}, // Combining Cyrillic Vzmet..Combining Cyrillic Thous + {0x0a674, 0x0a67d}, // Combining Cyrillic Lette..Combining Cyrillic Payer + {0x0a69e, 0x0a69f}, // Combining Cyrillic Lette..Combining Cyrillic Lette + {0x0a6f0, 0x0a6f1}, // Bamum Combining Mark Koq..Bamum Combining Mark Tuk + {0x0a802, 0x0a802}, // Syloti Nagri Sign Dvisva..Syloti Nagri Sign Dvisva + {0x0a806, 0x0a806}, // Syloti Nagri Sign Hasant..Syloti Nagri Sign Hasant + {0x0a80b, 0x0a80b}, // Syloti Nagri Sign Anusva..Syloti Nagri Sign Anusva + {0x0a825, 0x0a826}, // Syloti Nagri Vowel Sign ..Syloti Nagri Vowel Sign + {0x0a82c, 0x0a82c}, // Syloti Nagri Sign Altern..Syloti Nagri Sign Altern + {0x0a8c4, 0x0a8c5}, // Saurashtra Sign Virama ..Saurashtra Sign Candrabi + {0x0a8e0, 0x0a8f1}, // Combining Devanagari Dig..Combining Devanagari Sig + {0x0a8ff, 0x0a8ff}, // Devanagari Vowel Sign Ay..Devanagari Vowel Sign Ay + {0x0a926, 0x0a92d}, // Kayah Li Vowel Ue ..Kayah Li Tone Calya Plop + {0x0a947, 0x0a951}, // Rejang Vowel Sign I ..Rejang Consonant Sign R + {0x0a980, 0x0a982}, // Javanese Sign Panyangga ..Javanese Sign Layar + {0x0a9b3, 0x0a9b3}, // Javanese Sign Cecak Telu..Javanese Sign Cecak Telu + {0x0a9b6, 0x0a9b9}, // Javanese Vowel Sign Wulu..Javanese Vowel Sign Suku + {0x0a9bc, 0x0a9bd}, // Javanese Vowel Sign Pepe..Javanese Consonant Sign + {0x0a9e5, 0x0a9e5}, // Myanmar Sign Shan Saw ..Myanmar Sign Shan Saw + {0x0aa29, 0x0aa2e}, // Cham Vowel Sign Aa ..Cham Vowel Sign Oe + {0x0aa31, 0x0aa32}, // Cham Vowel Sign Au ..Cham Vowel Sign Ue + {0x0aa35, 0x0aa36}, // Cham Consonant Sign La ..Cham Consonant Sign Wa + {0x0aa43, 0x0aa43}, // Cham Consonant Sign Fina..Cham Consonant Sign Fina + {0x0aa4c, 0x0aa4c}, // Cham Consonant Sign Fina..Cham Consonant Sign Fina + {0x0aa7c, 0x0aa7c}, // Myanmar Sign Tai Laing T..Myanmar Sign Tai Laing T + {0x0aab0, 0x0aab0}, // Tai Viet Mai Kang ..Tai Viet Mai Kang + {0x0aab2, 0x0aab4}, // Tai Viet Vowel I ..Tai Viet Vowel U + {0x0aab7, 0x0aab8}, // Tai Viet Mai Khit ..Tai Viet Vowel Ia + {0x0aabe, 0x0aabf}, // Tai Viet Vowel Am ..Tai Viet Tone Mai Ek + {0x0aac1, 0x0aac1}, // Tai Viet Tone Mai Tho ..Tai Viet Tone Mai Tho + {0x0aaec, 0x0aaed}, // Meetei Mayek Vowel Sign ..Meetei Mayek Vowel Sign + {0x0aaf6, 0x0aaf6}, // Meetei Mayek Virama ..Meetei Mayek Virama + {0x0abe5, 0x0abe5}, // Meetei Mayek Vowel Sign ..Meetei Mayek Vowel Sign + {0x0abe8, 0x0abe8}, // Meetei Mayek Vowel Sign ..Meetei Mayek Vowel Sign + {0x0abed, 0x0abed}, // Meetei Mayek Apun Iyek ..Meetei Mayek Apun Iyek + {0x0fb1e, 0x0fb1e}, // Hebrew Point Judeo-spani..Hebrew Point Judeo-spani + {0x0fe00, 0x0fe0f}, // Variation Selector-1 ..Variation Selector-16 + {0x0fe20, 0x0fe2f}, // Combining Ligature Left ..Combining Cyrillic Titlo + {0x101fd, 0x101fd}, // Phaistos Disc Sign Combi..Phaistos Disc Sign Combi + {0x102e0, 0x102e0}, // Coptic Epact Thousands M..Coptic Epact Thousands M + {0x10376, 0x1037a}, // Combining Old Permic Let..Combining Old Permic Let + {0x10a01, 0x10a03}, // Kharoshthi Vowel Sign I ..Kharoshthi Vowel Sign Vo + {0x10a05, 0x10a06}, // Kharoshthi Vowel Sign E ..Kharoshthi Vowel Sign O + {0x10a0c, 0x10a0f}, // Kharoshthi Vowel Length ..Kharoshthi Sign Visarga + {0x10a38, 0x10a3a}, // Kharoshthi Sign Bar Abov..Kharoshthi Sign Dot Belo + {0x10a3f, 0x10a3f}, // Kharoshthi Virama ..Kharoshthi Virama + {0x10ae5, 0x10ae6}, // Manichaean Abbreviation ..Manichaean Abbreviation + {0x10d24, 0x10d27}, // Hanifi Rohingya Sign Har..Hanifi Rohingya Sign Tas + {0x10eab, 0x10eac}, // Yezidi Combining Hamza M..Yezidi Combining Madda M + {0x10efd, 0x10eff}, // (nil) ..(nil) + {0x10f46, 0x10f50}, // Sogdian Combining Dot Be..Sogdian Combining Stroke + {0x10f82, 0x10f85}, // Old Uyghur Combining Dot..Old Uyghur Combining Two + {0x11001, 0x11001}, // Brahmi Sign Anusvara ..Brahmi Sign Anusvara + {0x11038, 0x11046}, // Brahmi Vowel Sign Aa ..Brahmi Virama + {0x11070, 0x11070}, // Brahmi Sign Old Tamil Vi..Brahmi Sign Old Tamil Vi + {0x11073, 0x11074}, // Brahmi Vowel Sign Old Ta..Brahmi Vowel Sign Old Ta + {0x1107f, 0x11081}, // Brahmi Number Joiner ..Kaithi Sign Anusvara + {0x110b3, 0x110b6}, // Kaithi Vowel Sign U ..Kaithi Vowel Sign Ai + {0x110b9, 0x110ba}, // Kaithi Sign Virama ..Kaithi Sign Nukta + {0x110c2, 0x110c2}, // Kaithi Vowel Sign Vocali..Kaithi Vowel Sign Vocali + {0x11100, 0x11102}, // Chakma Sign Candrabindu ..Chakma Sign Visarga + {0x11127, 0x1112b}, // Chakma Vowel Sign A ..Chakma Vowel Sign Uu + {0x1112d, 0x11134}, // Chakma Vowel Sign Ai ..Chakma Maayyaa + {0x11173, 0x11173}, // Mahajani Sign Nukta ..Mahajani Sign Nukta + {0x11180, 0x11181}, // Sharada Sign Candrabindu..Sharada Sign Anusvara + {0x111b6, 0x111be}, // Sharada Vowel Sign U ..Sharada Vowel Sign O + {0x111c9, 0x111cc}, // Sharada Sandhi Mark ..Sharada Extra Short Vowe + {0x111cf, 0x111cf}, // Sharada Sign Inverted Ca..Sharada Sign Inverted Ca + {0x1122f, 0x11231}, // Khojki Vowel Sign U ..Khojki Vowel Sign Ai + {0x11234, 0x11234}, // Khojki Sign Anusvara ..Khojki Sign Anusvara + {0x11236, 0x11237}, // Khojki Sign Nukta ..Khojki Sign Shadda + {0x1123e, 0x1123e}, // Khojki Sign Sukun ..Khojki Sign Sukun + {0x11241, 0x11241}, // (nil) ..(nil) + {0x112df, 0x112df}, // Khudawadi Sign Anusvara ..Khudawadi Sign Anusvara + {0x112e3, 0x112ea}, // Khudawadi Vowel Sign U ..Khudawadi Sign Virama + {0x11300, 0x11301}, // Grantha Sign Combining A..Grantha Sign Candrabindu + {0x1133b, 0x1133c}, // Combining Bindu Below ..Grantha Sign Nukta + {0x11340, 0x11340}, // Grantha Vowel Sign Ii ..Grantha Vowel Sign Ii + {0x11366, 0x1136c}, // Combining Grantha Digit ..Combining Grantha Digit + {0x11370, 0x11374}, // Combining Grantha Letter..Combining Grantha Letter + {0x11438, 0x1143f}, // Newa Vowel Sign U ..Newa Vowel Sign Ai + {0x11442, 0x11444}, // Newa Sign Virama ..Newa Sign Anusvara + {0x11446, 0x11446}, // Newa Sign Nukta ..Newa Sign Nukta + {0x1145e, 0x1145e}, // Newa Sandhi Mark ..Newa Sandhi Mark + {0x114b3, 0x114b8}, // Tirhuta Vowel Sign U ..Tirhuta Vowel Sign Vocal + {0x114ba, 0x114ba}, // Tirhuta Vowel Sign Short..Tirhuta Vowel Sign Short + {0x114bf, 0x114c0}, // Tirhuta Sign Candrabindu..Tirhuta Sign Anusvara + {0x114c2, 0x114c3}, // Tirhuta Sign Virama ..Tirhuta Sign Nukta + {0x115b2, 0x115b5}, // Siddham Vowel Sign U ..Siddham Vowel Sign Vocal + {0x115bc, 0x115bd}, // Siddham Sign Candrabindu..Siddham Sign Anusvara + {0x115bf, 0x115c0}, // Siddham Sign Virama ..Siddham Sign Nukta + {0x115dc, 0x115dd}, // Siddham Vowel Sign Alter..Siddham Vowel Sign Alter + {0x11633, 0x1163a}, // Modi Vowel Sign U ..Modi Vowel Sign Ai + {0x1163d, 0x1163d}, // Modi Sign Anusvara ..Modi Sign Anusvara + {0x1163f, 0x11640}, // Modi Sign Virama ..Modi Sign Ardhacandra + {0x116ab, 0x116ab}, // Takri Sign Anusvara ..Takri Sign Anusvara + {0x116ad, 0x116ad}, // Takri Vowel Sign Aa ..Takri Vowel Sign Aa + {0x116b0, 0x116b5}, // Takri Vowel Sign U ..Takri Vowel Sign Au + {0x116b7, 0x116b7}, // Takri Sign Nukta ..Takri Sign Nukta + {0x1171d, 0x1171f}, // Ahom Consonant Sign Medi..Ahom Consonant Sign Medi + {0x11722, 0x11725}, // Ahom Vowel Sign I ..Ahom Vowel Sign Uu + {0x11727, 0x1172b}, // Ahom Vowel Sign Aw ..Ahom Sign Killer + {0x1182f, 0x11837}, // Dogra Vowel Sign U ..Dogra Sign Anusvara + {0x11839, 0x1183a}, // Dogra Sign Virama ..Dogra Sign Nukta + {0x1193b, 0x1193c}, // Dives Akuru Sign Anusvar..Dives Akuru Sign Candrab + {0x1193e, 0x1193e}, // Dives Akuru Virama ..Dives Akuru Virama + {0x11943, 0x11943}, // Dives Akuru Sign Nukta ..Dives Akuru Sign Nukta + {0x119d4, 0x119d7}, // Nandinagari Vowel Sign U..Nandinagari Vowel Sign V + {0x119da, 0x119db}, // Nandinagari Vowel Sign E..Nandinagari Vowel Sign A + {0x119e0, 0x119e0}, // Nandinagari Sign Virama ..Nandinagari Sign Virama + {0x11a01, 0x11a0a}, // Zanabazar Square Vowel S..Zanabazar Square Vowel L + {0x11a33, 0x11a38}, // Zanabazar Square Final C..Zanabazar Square Sign An + {0x11a3b, 0x11a3e}, // Zanabazar Square Cluster..Zanabazar Square Cluster + {0x11a47, 0x11a47}, // Zanabazar Square Subjoin..Zanabazar Square Subjoin + {0x11a51, 0x11a56}, // Soyombo Vowel Sign I ..Soyombo Vowel Sign Oe + {0x11a59, 0x11a5b}, // Soyombo Vowel Sign Vocal..Soyombo Vowel Length Mar + {0x11a8a, 0x11a96}, // Soyombo Final Consonant ..Soyombo Sign Anusvara + {0x11a98, 0x11a99}, // Soyombo Gemination Mark ..Soyombo Subjoiner + {0x11c30, 0x11c36}, // Bhaiksuki Vowel Sign I ..Bhaiksuki Vowel Sign Voc + {0x11c38, 0x11c3d}, // Bhaiksuki Vowel Sign E ..Bhaiksuki Sign Anusvara + {0x11c3f, 0x11c3f}, // Bhaiksuki Sign Virama ..Bhaiksuki Sign Virama + {0x11c92, 0x11ca7}, // Marchen Subjoined Letter..Marchen Subjoined Letter + {0x11caa, 0x11cb0}, // Marchen Subjoined Letter..Marchen Vowel Sign Aa + {0x11cb2, 0x11cb3}, // Marchen Vowel Sign U ..Marchen Vowel Sign E + {0x11cb5, 0x11cb6}, // Marchen Sign Anusvara ..Marchen Sign Candrabindu + {0x11d31, 0x11d36}, // Masaram Gondi Vowel Sign..Masaram Gondi Vowel Sign + {0x11d3a, 0x11d3a}, // Masaram Gondi Vowel Sign..Masaram Gondi Vowel Sign + {0x11d3c, 0x11d3d}, // Masaram Gondi Vowel Sign..Masaram Gondi Vowel Sign + {0x11d3f, 0x11d45}, // Masaram Gondi Vowel Sign..Masaram Gondi Virama + {0x11d47, 0x11d47}, // Masaram Gondi Ra-kara ..Masaram Gondi Ra-kara + {0x11d90, 0x11d91}, // Gunjala Gondi Vowel Sign..Gunjala Gondi Vowel Sign + {0x11d95, 0x11d95}, // Gunjala Gondi Sign Anusv..Gunjala Gondi Sign Anusv + {0x11d97, 0x11d97}, // Gunjala Gondi Virama ..Gunjala Gondi Virama + {0x11ef3, 0x11ef4}, // Makasar Vowel Sign I ..Makasar Vowel Sign U + {0x11f00, 0x11f01}, // (nil) ..(nil) + {0x11f36, 0x11f3a}, // (nil) ..(nil) + {0x11f40, 0x11f40}, // (nil) ..(nil) + {0x11f42, 0x11f42}, // (nil) ..(nil) + {0x13440, 0x13440}, // (nil) ..(nil) + {0x13447, 0x13455}, // (nil) ..(nil) + {0x16af0, 0x16af4}, // Bassa Vah Combining High..Bassa Vah Combining High + {0x16b30, 0x16b36}, // Pahawh Hmong Mark Cim Tu..Pahawh Hmong Mark Cim Ta + {0x16f4f, 0x16f4f}, // Miao Sign Consonant Modi..Miao Sign Consonant Modi + {0x16f8f, 0x16f92}, // Miao Tone Right ..Miao Tone Below + {0x16fe4, 0x16fe4}, // Khitan Small Script Fill..Khitan Small Script Fill + {0x1bc9d, 0x1bc9e}, // Duployan Thick Letter Se..Duployan Double Mark + {0x1cf00, 0x1cf2d}, // Znamenny Combining Mark ..Znamenny Combining Mark + {0x1cf30, 0x1cf46}, // Znamenny Combining Tonal..Znamenny Priznak Modifie + {0x1d167, 0x1d169}, // Musical Symbol Combining..Musical Symbol Combining + {0x1d17b, 0x1d182}, // Musical Symbol Combining..Musical Symbol Combining + {0x1d185, 0x1d18b}, // Musical Symbol Combining..Musical Symbol Combining + {0x1d1aa, 0x1d1ad}, // Musical Symbol Combining..Musical Symbol Combining + {0x1d242, 0x1d244}, // Combining Greek Musical ..Combining Greek Musical + {0x1da00, 0x1da36}, // Signwriting Head Rim ..Signwriting Air Sucking + {0x1da3b, 0x1da6c}, // Signwriting Mouth Closed..Signwriting Excitement + {0x1da75, 0x1da75}, // Signwriting Upper Body T..Signwriting Upper Body T + {0x1da84, 0x1da84}, // Signwriting Location Hea..Signwriting Location Hea + {0x1da9b, 0x1da9f}, // Signwriting Fill Modifie..Signwriting Fill Modifie + {0x1daa1, 0x1daaf}, // Signwriting Rotation Mod..Signwriting Rotation Mod + {0x1e000, 0x1e006}, // Combining Glagolitic Let..Combining Glagolitic Let + {0x1e008, 0x1e018}, // Combining Glagolitic Let..Combining Glagolitic Let + {0x1e01b, 0x1e021}, // Combining Glagolitic Let..Combining Glagolitic Let + {0x1e023, 0x1e024}, // Combining Glagolitic Let..Combining Glagolitic Let + {0x1e026, 0x1e02a}, // Combining Glagolitic Let..Combining Glagolitic Let + {0x1e08f, 0x1e08f}, // (nil) ..(nil) + {0x1e130, 0x1e136}, // Nyiakeng Puachue Hmong T..Nyiakeng Puachue Hmong T + {0x1e2ae, 0x1e2ae}, // Toto Sign Rising Tone ..Toto Sign Rising Tone + {0x1e2ec, 0x1e2ef}, // Wancho Tone Tup ..Wancho Tone Koini + {0x1e4ec, 0x1e4ef}, // (nil) ..(nil) + {0x1e8d0, 0x1e8d6}, // Mende Kikakui Combining ..Mende Kikakui Combining + {0x1e944, 0x1e94a}, // Adlam Alif Lengthener ..Adlam Nukta + {0xe0100, 0xe01ef}, // Variation Selector-17 ..Variation Selector-256 + }; + + // https://github.com/jquast/wcwidth/blob/master/wcwidth/table_wide.py + // from https://github.com/jquast/wcwidth/pull/64 + // at commit 1b9b6585b0080ea5cb88dc9815796505724793fe (2022-12-16): + private static final int[][] WIDE_EASTASIAN = { + {0x01100, 0x0115f}, // Hangul Choseong Kiyeok ..Hangul Choseong Filler + {0x0231a, 0x0231b}, // Watch ..Hourglass + {0x02329, 0x0232a}, // Left-pointing Angle Brac..Right-pointing Angle Bra + {0x023e9, 0x023ec}, // Black Right-pointing Dou..Black Down-pointing Doub + {0x023f0, 0x023f0}, // Alarm Clock ..Alarm Clock + {0x023f3, 0x023f3}, // Hourglass With Flowing S..Hourglass With Flowing S + {0x025fd, 0x025fe}, // White Medium Small Squar..Black Medium Small Squar + {0x02614, 0x02615}, // Umbrella With Rain Drops..Hot Beverage + {0x02648, 0x02653}, // Aries ..Pisces + {0x0267f, 0x0267f}, // Wheelchair Symbol ..Wheelchair Symbol + {0x02693, 0x02693}, // Anchor ..Anchor + {0x026a1, 0x026a1}, // High Voltage Sign ..High Voltage Sign + {0x026aa, 0x026ab}, // Medium White Circle ..Medium Black Circle + {0x026bd, 0x026be}, // Soccer Ball ..Baseball + {0x026c4, 0x026c5}, // Snowman Without Snow ..Sun Behind Cloud + {0x026ce, 0x026ce}, // Ophiuchus ..Ophiuchus + {0x026d4, 0x026d4}, // No Entry ..No Entry + {0x026ea, 0x026ea}, // Church ..Church + {0x026f2, 0x026f3}, // Fountain ..Flag In Hole + {0x026f5, 0x026f5}, // Sailboat ..Sailboat + {0x026fa, 0x026fa}, // Tent ..Tent + {0x026fd, 0x026fd}, // Fuel Pump ..Fuel Pump + {0x02705, 0x02705}, // White Heavy Check Mark ..White Heavy Check Mark + {0x0270a, 0x0270b}, // Raised Fist ..Raised Hand + {0x02728, 0x02728}, // Sparkles ..Sparkles + {0x0274c, 0x0274c}, // Cross Mark ..Cross Mark + {0x0274e, 0x0274e}, // Negative Squared Cross M..Negative Squared Cross M + {0x02753, 0x02755}, // Black Question Mark Orna..White Exclamation Mark O + {0x02757, 0x02757}, // Heavy Exclamation Mark S..Heavy Exclamation Mark S + {0x02795, 0x02797}, // Heavy Plus Sign ..Heavy Division Sign + {0x027b0, 0x027b0}, // Curly Loop ..Curly Loop + {0x027bf, 0x027bf}, // Double Curly Loop ..Double Curly Loop + {0x02b1b, 0x02b1c}, // Black Large Square ..White Large Square + {0x02b50, 0x02b50}, // White Medium Star ..White Medium Star + {0x02b55, 0x02b55}, // Heavy Large Circle ..Heavy Large Circle + {0x02e80, 0x02e99}, // Cjk Radical Repeat ..Cjk Radical Rap + {0x02e9b, 0x02ef3}, // Cjk Radical Choke ..Cjk Radical C-simplified + {0x02f00, 0x02fd5}, // Kangxi Radical One ..Kangxi Radical Flute + {0x02ff0, 0x02ffb}, // Ideographic Description ..Ideographic Description + {0x03000, 0x0303e}, // Ideographic Space ..Ideographic Variation In + {0x03041, 0x03096}, // Hiragana Letter Small A ..Hiragana Letter Small Ke + {0x03099, 0x030ff}, // Combining Katakana-hirag..Katakana Digraph Koto + {0x03105, 0x0312f}, // Bopomofo Letter B ..Bopomofo Letter Nn + {0x03131, 0x0318e}, // Hangul Letter Kiyeok ..Hangul Letter Araeae + {0x03190, 0x031e3}, // Ideographic Annotation L..Cjk Stroke Q + {0x031f0, 0x0321e}, // Katakana Letter Small Ku..Parenthesized Korean Cha + {0x03220, 0x03247}, // Parenthesized Ideograph ..Circled Ideograph Koto + {0x03250, 0x04dbf}, // Partnership Sign ..Cjk Unified Ideograph-4d + {0x04e00, 0x0a48c}, // Cjk Unified Ideograph-4e..Yi Syllable Yyr + {0x0a490, 0x0a4c6}, // Yi Radical Qot ..Yi Radical Ke + {0x0a960, 0x0a97c}, // Hangul Choseong Tikeut-m..Hangul Choseong Ssangyeo + {0x0ac00, 0x0d7a3}, // Hangul Syllable Ga ..Hangul Syllable Hih + {0x0f900, 0x0faff}, // Cjk Compatibility Ideogr..(nil) + {0x0fe10, 0x0fe19}, // Presentation Form For Ve..Presentation Form For Ve + {0x0fe30, 0x0fe52}, // Presentation Form For Ve..Small Full Stop + {0x0fe54, 0x0fe66}, // Small Semicolon ..Small Equals Sign + {0x0fe68, 0x0fe6b}, // Small Reverse Solidus ..Small Commercial At + {0x0ff01, 0x0ff60}, // Fullwidth Exclamation Ma..Fullwidth Right White Pa + {0x0ffe0, 0x0ffe6}, // Fullwidth Cent Sign ..Fullwidth Won Sign + {0x16fe0, 0x16fe4}, // Tangut Iteration Mark ..Khitan Small Script Fill + {0x16ff0, 0x16ff1}, // Vietnamese Alternate Rea..Vietnamese Alternate Rea + {0x17000, 0x187f7}, // (nil) ..(nil) + {0x18800, 0x18cd5}, // Tangut Component-001 ..Khitan Small Script Char + {0x18d00, 0x18d08}, // (nil) ..(nil) + {0x1aff0, 0x1aff3}, // Katakana Letter Minnan T..Katakana Letter Minnan T + {0x1aff5, 0x1affb}, // Katakana Letter Minnan T..Katakana Letter Minnan N + {0x1affd, 0x1affe}, // Katakana Letter Minnan N..Katakana Letter Minnan N + {0x1b000, 0x1b122}, // Katakana Letter Archaic ..Katakana Letter Archaic + {0x1b132, 0x1b132}, // (nil) ..(nil) + {0x1b150, 0x1b152}, // Hiragana Letter Small Wi..Hiragana Letter Small Wo + {0x1b155, 0x1b155}, // (nil) ..(nil) + {0x1b164, 0x1b167}, // Katakana Letter Small Wi..Katakana Letter Small N + {0x1b170, 0x1b2fb}, // Nushu Character-1b170 ..Nushu Character-1b2fb + {0x1f004, 0x1f004}, // Mahjong Tile Red Dragon ..Mahjong Tile Red Dragon + {0x1f0cf, 0x1f0cf}, // Playing Card Black Joker..Playing Card Black Joker + {0x1f18e, 0x1f18e}, // Negative Squared Ab ..Negative Squared Ab + {0x1f191, 0x1f19a}, // Squared Cl ..Squared Vs + {0x1f200, 0x1f202}, // Square Hiragana Hoka ..Squared Katakana Sa + {0x1f210, 0x1f23b}, // Squared Cjk Unified Ideo..Squared Cjk Unified Ideo + {0x1f240, 0x1f248}, // Tortoise Shell Bracketed..Tortoise Shell Bracketed + {0x1f250, 0x1f251}, // Circled Ideograph Advant..Circled Ideograph Accept + {0x1f260, 0x1f265}, // Rounded Symbol For Fu ..Rounded Symbol For Cai + {0x1f300, 0x1f320}, // Cyclone ..Shooting Star + {0x1f32d, 0x1f335}, // Hot Dog ..Cactus + {0x1f337, 0x1f37c}, // Tulip ..Baby Bottle + {0x1f37e, 0x1f393}, // Bottle With Popping Cork..Graduation Cap + {0x1f3a0, 0x1f3ca}, // Carousel Horse ..Swimmer + {0x1f3cf, 0x1f3d3}, // Cricket Bat And Ball ..Table Tennis Paddle And + {0x1f3e0, 0x1f3f0}, // House Building ..European Castle + {0x1f3f4, 0x1f3f4}, // Waving Black Flag ..Waving Black Flag + {0x1f3f8, 0x1f43e}, // Badminton Racquet And Sh..Paw Prints + {0x1f440, 0x1f440}, // Eyes ..Eyes + {0x1f442, 0x1f4fc}, // Ear ..Videocassette + {0x1f4ff, 0x1f53d}, // Prayer Beads ..Down-pointing Small Red + {0x1f54b, 0x1f54e}, // Kaaba ..Menorah With Nine Branch + {0x1f550, 0x1f567}, // Clock Face One Oclock ..Clock Face Twelve-thirty + {0x1f57a, 0x1f57a}, // Man Dancing ..Man Dancing + {0x1f595, 0x1f596}, // Reversed Hand With Middl..Raised Hand With Part Be + {0x1f5a4, 0x1f5a4}, // Black Heart ..Black Heart + {0x1f5fb, 0x1f64f}, // Mount Fuji ..Person With Folded Hands + {0x1f680, 0x1f6c5}, // Rocket ..Left Luggage + {0x1f6cc, 0x1f6cc}, // Sleeping Accommodation ..Sleeping Accommodation + {0x1f6d0, 0x1f6d2}, // Place Of Worship ..Shopping Trolley + {0x1f6d5, 0x1f6d7}, // Hindu Temple ..Elevator + {0x1f6dc, 0x1f6df}, // (nil) ..Ring Buoy + {0x1f6eb, 0x1f6ec}, // Airplane Departure ..Airplane Arriving + {0x1f6f4, 0x1f6fc}, // Scooter ..Roller Skate + {0x1f7e0, 0x1f7eb}, // Large Orange Circle ..Large Brown Square + {0x1f7f0, 0x1f7f0}, // Heavy Equals Sign ..Heavy Equals Sign + {0x1f90c, 0x1f93a}, // Pinched Fingers ..Fencer + {0x1f93c, 0x1f945}, // Wrestlers ..Goal Net + {0x1f947, 0x1f9ff}, // First Place Medal ..Nazar Amulet + {0x1fa70, 0x1fa7c}, // Ballet Shoes ..Crutch + {0x1fa80, 0x1fa88}, // Yo-yo ..(nil) + {0x1fa90, 0x1fabd}, // Ringed Planet ..(nil) + {0x1fabf, 0x1fac5}, // (nil) ..Person With Crown + {0x1face, 0x1fadb}, // (nil) ..(nil) + {0x1fae0, 0x1fae8}, // Melting Face ..(nil) + {0x1faf0, 0x1faf8}, // Hand With Index Finger A..(nil) + {0x20000, 0x2fffd}, // Cjk Unified Ideograph-20..(nil) + {0x30000, 0x3fffd}, // Cjk Unified Ideograph-30..(nil) + }; + + + private static boolean intable(int[][] table, int c) { + // First quick check f|| Latin1 etc. characters. + if (c < table[0][0]) return false; + + // Binary search in table. + int bot = 0; + int top = table.length - 1; // (int)(size / sizeof(struct interval) - 1); + while (top >= bot) { + int mid = (bot + top) / 2; + if (table[mid][1] < c) { + bot = mid + 1; + } else if (table[mid][0] > c) { + top = mid - 1; + } else { + return true; + } + } + return false; + } + + /** Return the terminal display width of a code point: 0, 1 || 2. */ + public static int width(int ucs) { + if (ucs == 0 || + ucs == 0x034F || + (0x200B <= ucs && ucs <= 0x200F) || + ucs == 0x2028 || + ucs == 0x2029 || + (0x202A <= ucs && ucs <= 0x202E) || + (0x2060 <= ucs && ucs <= 0x2063)) { + return 0; + } + + // C0/C1 control characters + // Termux change: Return 0 instead of -1. + if (ucs < 32 || (0x07F <= ucs && ucs < 0x0A0)) return 0; + + // combining characters with zero width + if (intable(ZERO_WIDTH, ucs)) return 0; + + return intable(WIDE_EASTASIAN, ucs) ? 2 : 1; + } + + /** The width at an index position in a java char array. */ + public static int width(char[] chars, int index) { + char c = chars[index]; + return Character.isHighSurrogate(c) ? width(Character.toCodePoint(c, chars[index + 1])) : width(c); + } + + /** + * The zero width characters count like combining characters in the `chars` array from start + * index to end index (exclusive). + */ + public static int zeroWidthCharsCount(char[] chars, int start, int end) { + if (start < 0 || start >= chars.length) + return 0; + + int count = 0; + for (int i = start; i < end && i < chars.length;) { + if (Character.isHighSurrogate(chars[i])) { + if (width(Character.toCodePoint(chars[i], chars[i + 1])) <= 0) { + count++; + } + i += 2; + } else { + if (width(chars[i]) <= 0) { + count++; + } + i++; + } + } + return count; + } + +} diff --git a/app/src/main/java/com/termux/view/TerminalRenderer.java b/app/src/main/java/com/termux/view/TerminalRenderer.java new file mode 100644 index 0000000..872ee3c --- /dev/null +++ b/app/src/main/java/com/termux/view/TerminalRenderer.java @@ -0,0 +1,253 @@ +package com.termux.view; + +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.PorterDuff; +import android.graphics.Typeface; + +import com.termux.terminal.TerminalBuffer; +import com.termux.terminal.TerminalEmulator; +import com.termux.terminal.TerminalRow; +import com.termux.terminal.TextStyle; +import com.termux.terminal.WcWidth; + +/** + * Renderer of a {@link TerminalEmulator} into a {@link Canvas}. + *

+ * Saves font metrics, so needs to be recreated each time the typeface or font size changes. + */ +public final class TerminalRenderer { + + final int mTextSize; + final Typeface mTypeface; + private final Paint mTextPaint = new Paint(); + + /** The width of a single mono spaced character obtained by {@link Paint#measureText(String)} on a single 'X'. */ + final float mFontWidth; + /** The {@link Paint#getFontSpacing()}. See http://www.fampennings.nl/maarten/android/08numgrid/font.png */ + final int mFontLineSpacing; + /** The {@link Paint#ascent()}. See http://www.fampennings.nl/maarten/android/08numgrid/font.png */ + private final int mFontAscent; + /** The {@link #mFontLineSpacing} + {@link #mFontAscent}. */ + final int mFontLineSpacingAndAscent; + + private final float[] asciiMeasures = new float[127]; + + public TerminalRenderer(int textSize, Typeface typeface) { + mTextSize = textSize; + mTypeface = typeface; + + mTextPaint.setTypeface(typeface); + mTextPaint.setAntiAlias(true); + mTextPaint.setTextSize(textSize); + + mFontLineSpacing = (int) Math.ceil(mTextPaint.getFontSpacing()); + mFontAscent = (int) Math.ceil(mTextPaint.ascent()); + mFontLineSpacingAndAscent = mFontLineSpacing + mFontAscent; + mFontWidth = mTextPaint.measureText("X"); + + StringBuilder sb = new StringBuilder(" "); + for (int i = 0; i < asciiMeasures.length; i++) { + sb.setCharAt(0, (char) i); + asciiMeasures[i] = mTextPaint.measureText(sb, 0, 1); + } + } + + /** Render the terminal to a canvas with at a specified row scroll, and an optional rectangular selection. */ + public final void render(TerminalEmulator mEmulator, Canvas canvas, int topRow, + int selectionY1, int selectionY2, int selectionX1, int selectionX2) { + final boolean reverseVideo = mEmulator.isReverseVideo(); + final int endRow = topRow + mEmulator.mRows; + final int columns = mEmulator.mColumns; + final int cursorCol = mEmulator.getCursorCol(); + final int cursorRow = mEmulator.getCursorRow(); + final boolean cursorVisible = mEmulator.shouldCursorBeVisible(); + final TerminalBuffer screen = mEmulator.getScreen(); + final int[] palette = mEmulator.mColors.mCurrentColors; + final int cursorShape = mEmulator.getCursorStyle(); + + if (reverseVideo) + canvas.drawColor(palette[TextStyle.COLOR_INDEX_FOREGROUND], PorterDuff.Mode.SRC); + + float heightOffset = mFontLineSpacingAndAscent; + for (int row = topRow; row < endRow; row++) { + heightOffset += mFontLineSpacing; + + final int cursorX = (row == cursorRow && cursorVisible) ? cursorCol : -1; + int selx1 = -1, selx2 = -1; + if (row >= selectionY1 && row <= selectionY2) { + if (row == selectionY1) selx1 = selectionX1; + selx2 = (row == selectionY2) ? selectionX2 : mEmulator.mColumns; + } + + TerminalRow lineObject = screen.allocateFullLineIfNecessary(screen.externalToInternalRow(row)); + final char[] line = lineObject.mText; + final int charsUsedInLine = lineObject.getSpaceUsed(); + + long lastRunStyle = 0; + boolean lastRunInsideCursor = false; + boolean lastRunInsideSelection = false; + int lastRunStartColumn = -1; + int lastRunStartIndex = 0; + boolean lastRunFontWidthMismatch = false; + int currentCharIndex = 0; + float measuredWidthForRun = 0.f; + + for (int column = 0; column < columns; ) { + final char charAtIndex = line[currentCharIndex]; + final boolean charIsHighsurrogate = Character.isHighSurrogate(charAtIndex); + final int charsForCodePoint = charIsHighsurrogate ? 2 : 1; + final int codePoint = charIsHighsurrogate ? Character.toCodePoint(charAtIndex, line[currentCharIndex + 1]) : charAtIndex; + 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 + // smileys which android font renders as wide. + // If this is detected, we draw this code point scaled to match what wcwidth() expects. + final float measuredCodePointWidth = (codePoint < asciiMeasures.length) ? asciiMeasures[codePoint] : mTextPaint.measureText(line, + currentCharIndex, charsForCodePoint); + final boolean fontWidthMismatch = Math.abs(measuredCodePointWidth / mFontWidth - codePointWcWidth) > 0.01; + + if (style != lastRunStyle || insideCursor != lastRunInsideCursor || insideSelection != lastRunInsideSelection || fontWidthMismatch || lastRunFontWidthMismatch) { + if (column == 0) { + // Skip first column as there is nothing to draw, just record the current style. + } else { + final int columnWidthSinceLastRun = column - lastRunStartColumn; + final int charsSinceLastRun = currentCharIndex - lastRunStartIndex; + int cursorColor = lastRunInsideCursor ? mEmulator.mColors.mCurrentColors[TextStyle.COLOR_INDEX_CURSOR] : 0; + boolean invertCursorTextColor = false; + if (lastRunInsideCursor && cursorShape == TerminalEmulator.TERMINAL_CURSOR_STYLE_BLOCK) { + invertCursorTextColor = true; + } + drawTextRun(canvas, line, palette, heightOffset, lastRunStartColumn, columnWidthSinceLastRun, + lastRunStartIndex, charsSinceLastRun, measuredWidthForRun, + cursorColor, cursorShape, lastRunStyle, reverseVideo || invertCursorTextColor || lastRunInsideSelection); + } + measuredWidthForRun = 0.f; + lastRunStyle = style; + lastRunInsideCursor = insideCursor; + lastRunInsideSelection = insideSelection; + lastRunStartColumn = column; + lastRunStartIndex = currentCharIndex; + lastRunFontWidthMismatch = fontWidthMismatch; + } + measuredWidthForRun += measuredCodePointWidth; + column += codePointWcWidth; + currentCharIndex += charsForCodePoint; + while (currentCharIndex < charsUsedInLine && WcWidth.width(line, currentCharIndex) <= 0) { + // Eat combining chars so that they are treated as part of the last non-combining code point, + // instead of e.g. being considered inside the cursor in the next run. + currentCharIndex += Character.isHighSurrogate(line[currentCharIndex]) ? 2 : 1; + } + } + + final int columnWidthSinceLastRun = columns - lastRunStartColumn; + final int charsSinceLastRun = currentCharIndex - lastRunStartIndex; + int cursorColor = lastRunInsideCursor ? mEmulator.mColors.mCurrentColors[TextStyle.COLOR_INDEX_CURSOR] : 0; + boolean invertCursorTextColor = false; + if (lastRunInsideCursor && cursorShape == TerminalEmulator.TERMINAL_CURSOR_STYLE_BLOCK) { + invertCursorTextColor = true; + } + drawTextRun(canvas, line, palette, heightOffset, lastRunStartColumn, columnWidthSinceLastRun, lastRunStartIndex, charsSinceLastRun, + measuredWidthForRun, cursorColor, cursorShape, lastRunStyle, reverseVideo || invertCursorTextColor || lastRunInsideSelection); + } + } + + private void drawTextRun(Canvas canvas, char[] text, int[] palette, float y, int startColumn, int runWidthColumns, + int startCharIndex, int runWidthChars, float mes, int cursor, int cursorStyle, + long textStyle, boolean reverseVideo) { + int foreColor = TextStyle.decodeForeColor(textStyle); + final int effect = TextStyle.decodeEffect(textStyle); + int backColor = TextStyle.decodeBackColor(textStyle); + final boolean bold = (effect & (TextStyle.CHARACTER_ATTRIBUTE_BOLD | TextStyle.CHARACTER_ATTRIBUTE_BLINK)) != 0; + final boolean underline = (effect & TextStyle.CHARACTER_ATTRIBUTE_UNDERLINE) != 0; + final boolean italic = (effect & TextStyle.CHARACTER_ATTRIBUTE_ITALIC) != 0; + final boolean strikeThrough = (effect & TextStyle.CHARACTER_ATTRIBUTE_STRIKETHROUGH) != 0; + final boolean dim = (effect & TextStyle.CHARACTER_ATTRIBUTE_DIM) != 0; + + if ((foreColor & 0xff000000) != 0xff000000) { + // Let bold have bright colors if applicable (one of the first 8): + if (bold && foreColor >= 0 && foreColor < 8) foreColor += 8; + foreColor = palette[foreColor]; + } + + if ((backColor & 0xff000000) != 0xff000000) { + backColor = palette[backColor]; + } + + // Reverse video here if _one and only one_ of the reverse flags are set: + final boolean reverseVideoHere = reverseVideo ^ (effect & (TextStyle.CHARACTER_ATTRIBUTE_INVERSE)) != 0; + if (reverseVideoHere) { + int tmp = foreColor; + foreColor = backColor; + backColor = tmp; + } + + float left = startColumn * mFontWidth; + float right = left + runWidthColumns * mFontWidth; + + mes = mes / mFontWidth; + boolean savedMatrix = false; + if (Math.abs(mes - runWidthColumns) > 0.01) { + canvas.save(); + canvas.scale(runWidthColumns / mes, 1.f); + left *= mes / runWidthColumns; + right *= mes / runWidthColumns; + savedMatrix = true; + } + + if (backColor != palette[TextStyle.COLOR_INDEX_BACKGROUND]) { + // Only draw non-default background. + mTextPaint.setColor(backColor); + canvas.drawRect(left, y - mFontLineSpacingAndAscent + mFontAscent, right, y, mTextPaint); + } + + 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.; + canvas.drawRect(left, y - cursorHeight, right, y, mTextPaint); + } + + if ((effect & TextStyle.CHARACTER_ATTRIBUTE_INVISIBLE) == 0) { + if (dim) { + int red = (0xFF & (foreColor >> 16)); + int green = (0xFF & (foreColor >> 8)); + int blue = (0xFF & foreColor); + // Dim color handling used by libvte which in turn took it from xterm + // (https://bug735245.bugzilla-attachments.gnome.org/attachment.cgi?id=284267): + red = red * 2 / 3; + green = green * 2 / 3; + blue = blue * 2 / 3; + foreColor = 0xFF000000 + (red << 16) + (green << 8) + blue; + } + + mTextPaint.setFakeBoldText(bold); + mTextPaint.setUnderlineText(underline); + mTextPaint.setTextSkewX(italic ? -0.35f : 0.f); + mTextPaint.setStrikeThruText(strikeThrough); + 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 (savedMatrix) canvas.restore(); + } + + public float getFontWidth() { + return mFontWidth; + } + + public int getFontLineSpacing() { + return mFontLineSpacing; + } + + public int getFontLineSpacingAndAscent() { + return mFontLineSpacingAndAscent; + } +} diff --git a/app/src/main/java/xyz/whcself/whcshell/Appearance.java b/app/src/main/java/xyz/whcself/whcshell/Appearance.java new file mode 100644 index 0000000..a8e78d6 --- /dev/null +++ b/app/src/main/java/xyz/whcself/whcshell/Appearance.java @@ -0,0 +1,91 @@ +package xyz.whcself.whcshell; + +import android.graphics.Typeface; +import android.util.Log; + +import com.termux.terminal.TerminalEmulator; + +import java.io.File; +import java.io.FileInputStream; +import java.io.InputStreamReader; +import java.io.Reader; +import java.nio.charset.StandardCharsets; +import java.util.Properties; + +/** + * Look and feel copied from Termux's ~/.termux (font.ttf, colors.properties, termux.properties), + * so the terminal looks the same as in the Termux app. + */ +final class Appearance { + + /** Files mirrored from the remote ~/.termux directory. */ + static final String[] FILES = {"font.ttf", "colors.properties", "termux.properties"}; + + final Typeface typeface; + /** Null when Termux has no colors.properties. */ + final Properties colors; + final int cursorStyle; + /** Cursor blink period in milliseconds, 0 for a steady cursor. */ + final int blinkMs; + final float marginHorizontalDp; + final float marginVerticalDp; + + private Appearance(Typeface typeface, Properties colors, Properties props) { + this.typeface = typeface; + this.colors = colors; + switch (props.getProperty("terminal-cursor-style", "block").trim()) { + case "underline": + cursorStyle = TerminalEmulator.TERMINAL_CURSOR_STYLE_UNDERLINE; + break; + case "bar": + cursorStyle = TerminalEmulator.TERMINAL_CURSOR_STYLE_BAR; + break; + default: + cursorStyle = TerminalEmulator.TERMINAL_CURSOR_STYLE_BLOCK; + } + int blink = intProp(props, "terminal-cursor-blink-rate", 0); + blinkMs = blink < 100 ? 0 : Math.min(blink, 2000); + marginHorizontalDp = Math.max(0, Math.min(100, intProp(props, "terminal-margin-horizontal", 3))); + marginVerticalDp = Math.max(0, Math.min(100, intProp(props, "terminal-margin-vertical", 0))); + } + + static File dir(File filesDir) { + return new File(filesDir, "termux"); + } + + static Appearance load(File filesDir) { + File d = dir(filesDir); + Typeface tf = Typeface.MONOSPACE; + File font = new File(d, "font.ttf"); + if (font.isFile()) { + try { + tf = Typeface.createFromFile(font); + } catch (RuntimeException e) { + Log.w("whc-shell", "bad font.ttf", e); + } + } + Properties colors = read(new File(d, "colors.properties")); + Properties props = read(new File(d, "termux.properties")); + return new Appearance(tf, colors, props == null ? new Properties() : props); + } + + private static Properties read(File f) { + if (!f.isFile()) return null; + Properties p = new Properties(); + try (Reader r = new InputStreamReader(new FileInputStream(f), StandardCharsets.UTF_8)) { + p.load(r); + return p; + } catch (Exception e) { + Log.w("whc-shell", "cannot read " + f, e); + return null; + } + } + + private static int intProp(Properties p, String key, int def) { + try { + return Integer.parseInt(p.getProperty(key, "").trim()); + } catch (NumberFormatException e) { + return def; + } + } +} diff --git a/app/src/main/java/xyz/whcself/whcshell/KeyEncoder.java b/app/src/main/java/xyz/whcself/whcshell/KeyEncoder.java new file mode 100644 index 0000000..cc09cf4 --- /dev/null +++ b/app/src/main/java/xyz/whcself/whcshell/KeyEncoder.java @@ -0,0 +1,144 @@ +package xyz.whcself.whcshell; + +import android.view.KeyEvent; + +import com.termux.terminal.KeyHandler; + +/** + * Turns a key press into the bytes a terminal application expects. + * + * Function keys use xterm's encoding (from {@link KeyHandler}). Text keys with modifiers follow, + * in order of preference, the kitty keyboard protocol (flag 1, "disambiguate"), xterm's + * modifyOtherKeys, or the legacy control-character / ESC-prefix encoding. + */ +final class KeyEncoder { + + static final int SHIFT = 1; + static final int ALT = 2; + static final int CTRL = 4; + + /** Terminal state that affects key encoding. */ + static final class Modes { + boolean cursorApp; + boolean keypadApp; + int kittyFlags; + int modifyOtherKeys; + } + + private KeyEncoder() { + } + + /** + * @param keyCode Android key code + * @param base the code point of the key without any modifier (e.g. 'a' for Shift+A), or 0 + * @param text the code point the key types with Shift applied (e.g. 'A'), or 0 + * @param mods {@link #SHIFT} | {@link #ALT} | {@link #CTRL} + * @param numLock whether Num Lock is on + * @return the bytes to send as a string, or null if the key produces nothing + */ + static String encode(int keyCode, int base, int text, int mods, boolean numLock, Modes m) { + boolean kitty = (m.kittyFlags & 1) != 0; + int param = 1 + mods; + + switch (keyCode) { + case KeyEvent.KEYCODE_ESCAPE: + if (kitty) return mods == 0 ? "\033[27u" : "\033[27;" + param + "u"; + return (mods & ALT) != 0 ? "\033\033" : "\033"; + case KeyEvent.KEYCODE_ENTER: + case KeyEvent.KEYCODE_NUMPAD_ENTER: + if (keyCode == KeyEvent.KEYCODE_NUMPAD_ENTER && m.keypadApp) break; + return special(13, "\r", mods, m); + case KeyEvent.KEYCODE_TAB: + if (!kitty && m.modifyOtherKeys < 2 && (mods & ~SHIFT) == 0) + return mods == 0 ? "\t" : "\033[Z"; + return special(9, "\t", mods, m); + case KeyEvent.KEYCODE_DEL: + return special(127, "\u007f", mods, m); + case KeyEvent.KEYCODE_PAGE_UP: + return mods == 0 ? "\033[5~" : "\033[5;" + param + "~"; + case KeyEvent.KEYCODE_PAGE_DOWN: + return mods == 0 ? "\033[6~" : "\033[6;" + param + "~"; + case KeyEvent.KEYCODE_SPACE: + return textKey(' ', ' ', mods, m); + default: + break; + } + + int keyMode = 0; + if ((mods & SHIFT) != 0) keyMode |= KeyHandler.KEYMOD_SHIFT; + if ((mods & ALT) != 0) keyMode |= KeyHandler.KEYMOD_ALT; + if ((mods & CTRL) != 0) keyMode |= KeyHandler.KEYMOD_CTRL; + if (numLock) keyMode |= KeyHandler.KEYMOD_NUM_LOCK; + String code = KeyHandler.getCode(keyCode, keyMode, m.cursorApp, m.keypadApp); + if (code != null) return code; + + if (text == 0) return null; + return textKey(base == 0 ? text : base, text, mods, m); + } + + /** Enter, Tab and Backspace: legacy bytes unless a modifier makes them ambiguous. */ + private static String special(int cp, String plain, int mods, Modes m) { + if (mods == 0) return plain; + if ((m.kittyFlags & 1) != 0) return "\033[" + cp + ";" + (1 + mods) + "u"; + if (m.modifyOtherKeys >= 2 || (m.modifyOtherKeys == 1 && cp == 13)) + return "\033[27;" + (1 + mods) + ";" + cp + "~"; + if (cp == 127 && (mods & CTRL) != 0) plain = "\b"; + return (mods & ALT) != 0 ? "\033" + plain : plain; + } + + private static String textKey(int base, int text, int mods, Modes m) { + boolean ctrl = (mods & CTRL) != 0, alt = (mods & ALT) != 0; + if (!ctrl && !alt) return new String(Character.toChars(text)); + + if ((m.kittyFlags & 1) != 0) { + if (base >= 'A' && base <= 'Z') base += 'a' - 'A'; + return "\033[" + base + ";" + (1 + mods) + "u"; + } + + int legacy = ctrl ? ctrlChar(text) : text; + boolean hasLegacy = !ctrl || legacy != text; + // Ctrl+Shift+letter would collapse onto Ctrl+letter. + if (ctrl && (mods & SHIFT) != 0 && Character.isLetter(text)) hasLegacy = false; + if (m.modifyOtherKeys >= 2 || (m.modifyOtherKeys == 1 && !hasLegacy)) + return "\033[27;" + (1 + mods) + ";" + text + "~"; + + StringBuilder sb = new StringBuilder(4); + if (alt) sb.append('\033'); + sb.appendCodePoint(legacy); + return sb.toString(); + } + + /** The control character xterm sends for Ctrl plus {@code cp}, or {@code cp} if there is none. */ + static int ctrlChar(int cp) { + if (cp >= 'a' && cp <= 'z') return cp - 'a' + 1; + if (cp >= 'A' && cp <= 'Z') return cp - 'A' + 1; + switch (cp) { + case ' ': + case '2': + case '@': + return 0; + case '3': + case '[': + return 27; + case '4': + case '\\': + return 28; + case '5': + case ']': + return 29; + case '6': + case '^': + return 30; + case '7': + case '-': + case '_': + case '/': + return 31; + case '8': + case '?': + return 127; + default: + return cp; + } + } +} diff --git a/app/src/main/java/xyz/whcself/whcshell/MainActivity.java b/app/src/main/java/xyz/whcself/whcshell/MainActivity.java new file mode 100644 index 0000000..0ee0366 --- /dev/null +++ b/app/src/main/java/xyz/whcself/whcshell/MainActivity.java @@ -0,0 +1,343 @@ +package xyz.whcself.whcshell; + +import android.app.Activity; +import android.app.AlertDialog; +import android.content.ClipData; +import android.content.ClipboardManager; +import android.content.Intent; +import android.content.SharedPreferences; +import android.content.pm.PackageManager; +import android.graphics.Insets; +import android.os.Build; +import android.os.Bundle; +import android.text.InputType; +import android.util.Log; +import android.util.TypedValue; +import android.view.ContextMenu; +import android.view.WindowInsets; +import android.view.WindowInsetsController; +import android.widget.EditText; +import android.widget.FrameLayout; +import android.widget.LinearLayout; +import android.widget.TextView; +import android.widget.Toast; + +import com.termux.terminal.TerminalColors; + +import java.util.Properties; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Consumer; + +public final class MainActivity extends Activity implements TermSession.Host, TermView.Actions { + + private static final float DEFAULT_SP = 14f; + private static final float MIN_SP = 6f; + private static final float MAX_SP = 40f; + private static final String RUN_COMMAND_PERMISSION = "com.termux.permission.RUN_COMMAND"; + private static final int REQ_RUN_COMMAND = 1; + + private TermSession session; + private TermView view; + private SharedPreferences prefs; + private float textSp; + private boolean fullscreen; + private Appearance appearance; + private String primary; + private Consumer pendingSshdReply; + + @Override + protected void onCreate(Bundle state) { + super.onCreate(state); + prefs = getSharedPreferences("ui", MODE_PRIVATE); + textSp = prefs.getFloat("text_sp", DEFAULT_SP); + fullscreen = prefs.getBoolean("fullscreen", false); + + FrameLayout root = new FrameLayout(this); + view = new TermView(this); + root.addView(view, new FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT)); + if (Build.VERSION.SDK_INT >= 30) { + root.setOnApplyWindowInsetsListener((v, insets) -> { + Insets i = insets.getInsets(WindowInsets.Type.systemBars() + | WindowInsets.Type.ime() | WindowInsets.Type.displayCutout()); + v.setPadding(i.left, i.top, i.right, i.bottom); + return WindowInsets.CONSUMED; + }); + } else { + root.setFitsSystemWindows(true); + } + setContentView(root); + + session = ((WhcApp) getApplication()).session(); + session.setHost(this); + applyAppearance(); + view.attach(session, this); + applyFullscreen(); + view.requestFocus(); + } + + @Override + @SuppressWarnings("deprecation") + public void onBackPressed() { + moveTaskToBack(true); + } + + @Override + protected void onDestroy() { + if (session != null) session.setHost(null); + super.onDestroy(); + } + + private float dp(float v) { + return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, v, getResources().getDisplayMetrics()); + } + + private int textPx() { + return Math.round(TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, textSp, + getResources().getDisplayMetrics())); + } + + private void applyAppearance() { + appearance = Appearance.load(getFilesDir()); + Properties colors = appearance.colors == null ? new Properties() : appearance.colors; + try { + TerminalColors.COLOR_SCHEME.updateWith(colors); + } catch (IllegalArgumentException e) { + Log.w("whc-shell", "bad colors.properties", e); + TerminalColors.COLOR_SCHEME.updateWith(new Properties()); + } + session.resetColors(); + session.setCursorStyle(appearance.cursorStyle); + int h = Math.round(dp(appearance.marginHorizontalDp)); + int v = Math.round(dp(appearance.marginVerticalDp)); + view.setPadding(h, v, h, v); + view.setFont(appearance.typeface, textPx()); + view.setBlink(appearance.blinkMs); + } + + private void applyFullscreen() { + if (Build.VERSION.SDK_INT < 30) return; + WindowInsetsController c = getWindow().getInsetsController(); + if (c == null) return; + if (fullscreen) { + c.setSystemBarsBehavior(WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE); + c.hide(WindowInsets.Type.systemBars()); + } else { + c.show(WindowInsets.Type.systemBars()); + } + } + + private void setClipboard(String text) { + ClipboardManager cm = getSystemService(ClipboardManager.class); + if (cm != null) cm.setPrimaryClip(ClipData.newPlainText("terminal", text)); + } + + private void pasteText(String text) { + if (text != null && !text.isEmpty() && session.emulator() != null && session.isConnected()) { + view.clearSelection(); + session.emulator().paste(text); + } + } + + // TermView.Actions + + @Override + public void paste() { + ClipboardManager cm = getSystemService(ClipboardManager.class); + ClipData clip = cm == null ? null : cm.getPrimaryClip(); + if (clip == null || clip.getItemCount() == 0) return; + CharSequence text = clip.getItemAt(0).coerceToText(this); + if (text != null) pasteText(text.toString()); + } + + @Override + public void pastePrimary() { + if (primary != null) pasteText(primary); + else paste(); + } + + @Override + public void copy(String text) { + setClipboard(text); + view.clearSelection(); + } + + @Override + public void setPrimary(String text) { + primary = text; + } + + @Override + public void zoom(int delta) { + textSp = delta == 0 ? DEFAULT_SP : Math.max(MIN_SP, Math.min(MAX_SP, textSp + delta)); + prefs.edit().putFloat("text_sp", textSp).apply(); + view.setTextSizePx(textPx()); + } + + @Override + public void toggleFullscreen() { + fullscreen = !fullscreen; + prefs.edit().putBoolean("fullscreen", fullscreen).apply(); + applyFullscreen(); + } + + @Override + public void buildMenu(ContextMenu menu, String selection) { + if (selection != null) + menu.add("复制 Ctrl+Shift+C").setOnMenuItemClickListener(i -> { + copy(selection); + return true; + }); + menu.add("粘贴 Ctrl+Shift+V").setOnMenuItemClickListener(i -> { + paste(); + return true; + }); + menu.add("全选 Ctrl+Shift+A").setOnMenuItemClickListener(i -> { + view.selectAll(); + return true; + }); + menu.add("放大 Ctrl+Shift+=").setOnMenuItemClickListener(i -> { + zoom(1); + return true; + }); + menu.add("缩小 Ctrl+Shift+-").setOnMenuItemClickListener(i -> { + zoom(-1); + return true; + }); + menu.add((fullscreen ? "退出全屏" : "全屏") + " F11").setOnMenuItemClickListener(i -> { + toggleFullscreen(); + return true; + }); + if (!session.isConnected() && !session.isConnecting()) + menu.add("重新连接").setOnMenuItemClickListener(i -> { + session.connect(); + return true; + }); + } + + // TermSession.Host + + @Override + public void onScreenUpdated() { + view.onScreenUpdated(); + } + + @Override + public void onTitleChanged(String title) { + setTitle(title == null || title.isEmpty() ? getString(R.string.app_name) : title); + } + + @Override + public void onCopy(String text) { + setClipboard(text); + } + + @Override + public void onPasteRequest() { + paste(); + } + + @Override + public void onBell() { + } + + @Override + public void onAppearanceSynced() { + applyAppearance(); + } + + @Override + public void startSshd(Consumer reply) { + try { + getPackageManager().getPackageInfo("com.termux", 0); + } catch (PackageManager.NameNotFoundException e) { + reply.accept(false); + return; + } + if (checkSelfPermission(RUN_COMMAND_PERMISSION) == PackageManager.PERMISSION_GRANTED) { + reply.accept(sendRunSshd()); + return; + } + if (pendingSshdReply != null) pendingSshdReply.accept(false); + pendingSshdReply = reply; + requestPermissions(new String[]{RUN_COMMAND_PERMISSION}, REQ_RUN_COMMAND); + } + + @Override + public void onRequestPermissionsResult(int code, String[] permissions, int[] results) { + super.onRequestPermissionsResult(code, permissions, results); + if (code != REQ_RUN_COMMAND || pendingSshdReply == null) return; + Consumer reply = pendingSshdReply; + pendingSshdReply = null; + boolean granted = results.length > 0 && results[0] == PackageManager.PERMISSION_GRANTED; + if (!granted) Toast.makeText(this, "未授权“在 Termux 中运行命令”,请手动在 Termux 里运行 sshd", + Toast.LENGTH_LONG).show(); + reply.accept(granted && sendRunSshd()); + } + + private boolean sendRunSshd() { + Intent i = new Intent("com.termux.RUN_COMMAND"); + i.setClassName("com.termux", "com.termux.app.RunCommandService"); + i.putExtra("com.termux.RUN_COMMAND_PATH", "/data/data/com.termux/files/usr/bin/sshd"); + i.putExtra("com.termux.RUN_COMMAND_BACKGROUND", true); + try { + startService(i); + return true; + } catch (RuntimeException e) { + Log.w("whc-shell", "RUN_COMMAND failed", e); + return false; + } + } + + @Override + public void askPassword(String message, Consumer reply) { + int pad = Math.round(dp(20)); + LinearLayout box = new LinearLayout(this); + box.setOrientation(LinearLayout.VERTICAL); + box.setPadding(pad, pad / 2, pad, 0); + TextView msg = new TextView(this); + msg.setText(message); + EditText pw = new EditText(this); + pw.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD); + pw.setHint("Termux 密码"); + box.addView(msg); + box.addView(pw); + + AtomicBoolean answered = new AtomicBoolean(); + AlertDialog d = new AlertDialog.Builder(this) + .setTitle("登录 Termux") + .setView(box) + .setPositiveButton("登录", (dlg, w) -> { + if (answered.compareAndSet(false, true)) reply.accept(pw.getText().toString()); + }) + .setNegativeButton("取消", null) + .setOnDismissListener(dlg -> { + if (answered.compareAndSet(false, true)) reply.accept(null); + view.requestFocus(); + }) + .create(); + pw.setOnEditorActionListener((tv, action, ev) -> { + if (answered.compareAndSet(false, true)) reply.accept(pw.getText().toString()); + d.dismiss(); + return true; + }); + d.show(); + pw.requestFocus(); + } + + @Override + public void confirm(String message, Consumer reply) { + AtomicBoolean answered = new AtomicBoolean(); + new AlertDialog.Builder(this) + .setMessage(message) + .setPositiveButton("信任", (dlg, w) -> { + if (answered.compareAndSet(false, true)) reply.accept(true); + }) + .setNegativeButton("拒绝", null) + .setOnDismissListener(dlg -> { + if (answered.compareAndSet(false, true)) reply.accept(false); + view.requestFocus(); + }) + .show(); + } +} diff --git a/app/src/main/java/xyz/whcself/whcshell/SshKeys.java b/app/src/main/java/xyz/whcself/whcshell/SshKeys.java new file mode 100644 index 0000000..069e765 --- /dev/null +++ b/app/src/main/java/xyz/whcself/whcshell/SshKeys.java @@ -0,0 +1,69 @@ +package xyz.whcself.whcshell; + +import org.bouncycastle.crypto.AsymmetricCipherKeyPair; +import org.bouncycastle.crypto.generators.Ed25519KeyPairGenerator; +import org.bouncycastle.crypto.params.Ed25519KeyGenerationParameters; +import org.bouncycastle.crypto.util.OpenSSHPrivateKeyUtil; +import org.bouncycastle.crypto.util.OpenSSHPublicKeyUtil; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.security.SecureRandom; +import java.util.Base64; + +/** The client's Ed25519 identity, stored in OpenSSH format in a private directory. */ +final class SshKeys { + + final File privateKey; + final File publicKey; + + private SshKeys(File dir) { + privateKey = new File(dir, "id_ed25519"); + publicKey = new File(dir, "id_ed25519.pub"); + } + + /** Loads the key pair from {@code dir}, generating it first if missing. */ + static SshKeys ensure(File dir, String comment) throws IOException { + SshKeys k = new SshKeys(dir); + if (k.privateKey.isFile() && k.publicKey.isFile()) return k; + if (!dir.isDirectory() && !dir.mkdirs()) throw new IOException("cannot create " + dir); + + Ed25519KeyPairGenerator gen = new Ed25519KeyPairGenerator(); + gen.init(new Ed25519KeyGenerationParameters(new SecureRandom())); + AsymmetricCipherKeyPair pair = gen.generateKeyPair(); + + Base64.Encoder b64 = Base64.getEncoder(); + String priv = "-----BEGIN OPENSSH PRIVATE KEY-----\n" + + Base64.getMimeEncoder(70, new byte[]{'\n'}).encodeToString( + OpenSSHPrivateKeyUtil.encodePrivateKey(pair.getPrivate())) + + "\n-----END OPENSSH PRIVATE KEY-----\n"; + String pub = "ssh-ed25519 " + + b64.encodeToString(OpenSSHPublicKeyUtil.encodePublicKey(pair.getPublic())) + + " " + comment.replaceAll("[^A-Za-z0-9._@-]", "_") + "\n"; + + write(k.privateKey, priv); + write(k.publicKey, pub); + return k; + } + + /** The single authorized_keys line, without a trailing newline. */ + String publicLine() throws IOException { + return new String(Files.readAllBytes(publicKey.toPath()), StandardCharsets.UTF_8).trim(); + } + + private static void write(File f, String s) throws IOException { + File tmp = new File(f.getPath() + ".tmp"); + try (FileOutputStream out = new FileOutputStream(tmp)) { + out.write(s.getBytes(StandardCharsets.UTF_8)); + out.getFD().sync(); + } + tmp.setReadable(false, false); + tmp.setReadable(true, true); + tmp.setWritable(false, false); + tmp.setWritable(true, true); + if (!tmp.renameTo(f)) throw new IOException("cannot rename " + tmp); + } +} diff --git a/app/src/main/java/xyz/whcself/whcshell/TermSession.java b/app/src/main/java/xyz/whcself/whcshell/TermSession.java new file mode 100644 index 0000000..a25d49f --- /dev/null +++ b/app/src/main/java/xyz/whcself/whcshell/TermSession.java @@ -0,0 +1,508 @@ +package xyz.whcself.whcshell; + +import android.content.Context; +import android.content.SharedPreferences; +import android.content.pm.PackageManager; +import android.os.Build; +import android.os.Handler; +import android.os.Looper; +import android.util.Log; + +import com.termux.terminal.ByteQueue; +import com.termux.terminal.TerminalEmulator; +import com.termux.terminal.TerminalOutput; +import com.termux.terminal.TerminalSessionClient; + +import net.schmizz.sshj.SSHClient; +import net.schmizz.sshj.common.Buffer; +import net.schmizz.sshj.connection.channel.direct.Session; +import net.schmizz.sshj.sftp.FileAttributes; +import net.schmizz.sshj.sftp.SFTPClient; +import net.schmizz.sshj.transport.verification.HostKeyVerifier; +import net.schmizz.sshj.userauth.UserAuthException; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.ConnectException; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.PublicKey; +import java.util.Base64; +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; + +/** + * A terminal emulator fed by an SSH shell channel to the local Termux sshd. + * + * All emulator access happens on the main thread; the connection thread only moves bytes. + */ +public final class TermSession extends TerminalOutput implements TerminalSessionClient { + + public interface Host { + void onScreenUpdated(); + + void onTitleChanged(String title); + + void onCopy(String text); + + void onPasteRequest(); + + void onBell(); + + /** Shows a password prompt; {@code reply} receives null when cancelled. */ + void askPassword(String message, Consumer reply); + + void confirm(String message, Consumer reply); + + /** Asks Termux to start sshd; {@code reply} receives whether the request was sent. */ + void startSshd(Consumer reply); + + /** Files under {@link Appearance#dir} changed. */ + void onAppearanceSynced(); + } + + private static final String TAG = "whc-shell"; + private static final int TRANSCRIPT_ROWS = 10000; + + static final String HOST = "127.0.0.1"; + static final int PORT = 8022; + + private final Context app; + private final SharedPreferences prefs; + private final Handler main = new Handler(Looper.getMainLooper()); + private final byte[] drainBuf = new byte[16 * 1024]; + + private Host host; + private TerminalEmulator emulator; + private volatile int cols, rows, cellW, cellH; + private Conn conn; + private boolean connected; + private int cursorStyle = TerminalEmulator.DEFAULT_TERMINAL_CURSOR_STYLE; + + TermSession(Context app) { + this.app = app; + this.prefs = app.getSharedPreferences("session", Context.MODE_PRIVATE); + } + + public void setHost(Host h) { + host = h; + } + + public TerminalEmulator emulator() { + return emulator; + } + + public boolean isConnected() { + return connected; + } + + public boolean isConnecting() { + return conn != null && !connected; + } + + public void setCursorStyle(int style) { + cursorStyle = style; + if (emulator != null) emulator.setCursorStyle(); + } + + /** Re-reads the color scheme into the emulator after {@code TerminalColors.COLOR_SCHEME} changed. */ + public void resetColors() { + if (emulator != null) emulator.mColors.reset(); + } + + /** Called by the view whenever its size in cells changes; the first call starts the session. */ + public void resize(int c, int r, int cw, int ch) { + cols = c; + rows = r; + cellW = cw; + cellH = ch; + if (emulator == null) { + emulator = new TerminalEmulator(this, c, r, cw, ch, TRANSCRIPT_ROWS, this); + connect(); + } else { + emulator.resize(c, r, cw, ch); + if (conn != null) conn.resize(c, r, c * cw, r * ch); + } + } + + public void connect() { + if (emulator == null || conn != null) return; + conn = new Conn(); + conn.start(); + } + + private void local(String text) { + byte[] b = text.getBytes(StandardCharsets.UTF_8); + emulator.append(b, b.length); + if (host != null) host.onScreenUpdated(); + } + + private void localFromAnyThread(String text) { + main.post(() -> local(text)); + } + + // TerminalOutput: bytes the user (or the emulator, for replies) sends to the remote side. + + @Override + public void write(byte[] data, int offset, int count) { + if (conn != null && connected) conn.send(data, offset, count); + } + + @Override + public void titleChanged(String oldTitle, String newTitle) { + if (host != null) host.onTitleChanged(newTitle); + } + + @Override + public void onCopyTextToClipboard(String text) { + if (host != null) host.onCopy(text); + } + + @Override + public void onPasteTextFromClipboard() { + if (host != null) host.onPasteRequest(); + } + + @Override + public void onBell() { + if (host != null) host.onBell(); + } + + @Override + public void onColorsChanged() { + if (host != null) host.onScreenUpdated(); + } + + // TerminalSessionClient + + @Override + public void onTerminalCursorStateChange(boolean state) { + } + + @Override + public Integer getTerminalCursorStyle() { + return cursorStyle; + } + + @Override + public void logError(String tag, String message) { + Log.e(TAG, tag + ": " + message); + } + + @Override + public void logWarn(String tag, String message) { + Log.w(TAG, tag + ": " + message); + } + + @Override + public void logInfo(String tag, String message) { + Log.i(TAG, tag + ": " + message); + } + + @Override + public void logDebug(String tag, String message) { + Log.d(TAG, tag + ": " + message); + } + + @Override + public void logVerbose(String tag, String message) { + Log.v(TAG, tag + ": " + message); + } + + @Override + public void logStackTraceWithMessage(String tag, String message, Exception e) { + Log.e(TAG, tag + ": " + message, e); + } + + @Override + public void logStackTrace(String tag, Exception e) { + Log.e(TAG, tag, e); + } + + /** Termux's Linux user name, e.g. u0_a315. Termux sshd ignores it, but stock sshd would not. */ + private String termuxUser() { + try { + int uid = app.getPackageManager().getApplicationInfo("com.termux", 0).uid; + return "u" + (uid / 100000) + "_a" + (uid % 100000 - 10000); + } catch (PackageManager.NameNotFoundException e) { + return "termux"; + } + } + + private T askOnMain(Consumer> ask) throws InterruptedException { + CountDownLatch done = new CountDownLatch(1); + AtomicReference result = new AtomicReference<>(); + main.post(() -> { + if (host == null) { + done.countDown(); + return; + } + ask.accept(v -> { + result.set(v); + done.countDown(); + }); + }); + done.await(); + return result.get(); + } + + private static String fingerprint(PublicKey key) { + try { + byte[] blob = new Buffer.PlainBuffer().putPublicKey(key).getCompactData(); + byte[] d = MessageDigest.getInstance("SHA-256").digest(blob); + return "SHA256:" + Base64.getEncoder().withoutPadding().encodeToString(d); + } catch (Exception e) { + throw new IllegalStateException(e); + } + } + + private static String describe(Throwable e) { + if (e instanceof ConnectException) + return "连不上 " + HOST + ":" + PORT + ",请先在 Termux 里运行 sshd"; + String m = e.getMessage(); + return m == null || m.isEmpty() ? e.getClass().getSimpleName() : m; + } + + private final class Conn extends Thread implements HostKeyVerifier { + private final ByteQueue queue = new ByteQueue(64 * 1024); + private final AtomicBoolean posted = new AtomicBoolean(); + private final ExecutorService io = Executors.newSingleThreadExecutor(); + private volatile SSHClient ssh; + private volatile Session.Shell shell; + private volatile OutputStream out; + + Conn() { + super("ssh-conn"); + setDaemon(true); + } + + @Override + public void run() { + String err = null; + try { + session(); + } catch (Exception e) { + Log.w(TAG, "session ended", e); + err = describe(e); + } + queue.close(); + io.shutdownNow(); + try { + if (ssh != null) ssh.disconnect(); + } catch (IOException ignored) { + } + String why = err; + main.post(() -> finished(why)); + } + + private SSHClient open() throws Exception { + for (int attempt = 0; ; attempt++) { + SSHClient c = new SSHClient(); + c.setConnectTimeout(5000); + c.addHostKeyVerifier(this); + try { + c.connect(HOST, PORT); + return c; + } catch (ConnectException e) { + c.close(); + if (attempt == 0) { + Boolean sent = askOnMain(reply -> host.startSshd(reply)); + if (sent == null || !sent) throw e; + localFromAnyThread("\033[2m已请求 Termux 启动 sshd …\033[0m\r\n"); + } else if (attempt >= 8) { + throw new IOException("Termux 没有启动 sshd。请在 ~/.termux/termux.properties 里设置 " + + "allow-external-apps = true,或手动在 Termux 里运行 sshd", e); + } + Thread.sleep(500); + } + } + } + + private void session() throws Exception { + localFromAnyThread("\033[2m正在连接 Termux (" + HOST + ":" + PORT + ") …\033[0m\r\n"); + SSHClient c = open(); + ssh = c; + authenticate(c); + + Session s = c.startSession(); + s.allocatePTY("xterm-256color", cols, rows, cols * cellW, rows * cellH, + Collections.emptyMap()); + Session.Shell sh = s.startShell(); + shell = sh; + out = sh.getOutputStream(); + main.post(() -> { + if (conn == this) connected = true; + }); + Thread sync = new Thread(() -> syncAppearance(c), "ssh-sync"); + sync.setDaemon(true); + sync.start(); + + InputStream in = sh.getInputStream(); + byte[] buf = new byte[8192]; + int n; + while ((n = in.read(buf)) > 0) { + if (!queue.write(buf, 0, n)) break; + if (posted.compareAndSet(false, true)) main.post(this::drain); + } + } + + private void drain() { + posted.set(false); + int n; + boolean any = false; + while ((n = queue.read(drainBuf, false)) > 0) { + emulator.append(drainBuf, n); + any = true; + } + if (any && host != null) host.onScreenUpdated(); + } + + private void authenticate(SSHClient c) throws Exception { + String user = termuxUser(); + SshKeys keys = SshKeys.ensure(app.getFilesDir(), "whc-shell@" + Build.MODEL); + try { + c.authPublickey(user, c.loadKeys(keys.privateKey.getPath())); + return; + } catch (UserAuthException ignored) { + } + String msg = "首次连接需要 Termux 的登录密码(在 Termux 里用 passwd 设置)。\n" + + "成功后会把本机公钥加入 ~/.ssh/authorized_keys,以后自动用密钥登录。"; + for (int i = 0; i < 3; i++) { + String m = msg; + String pw = askOnMain(reply -> host.askPassword(m, reply)); + if (pw == null) throw new IOException("已取消登录"); + try { + c.authPassword(user, pw); + installKey(c, keys.publicLine()); + return; + } catch (UserAuthException e) { + msg = "密码不对,请重试。"; + } + } + throw new IOException("密码验证失败"); + } + + private void installKey(SSHClient c, String line) throws IOException { + String q = "'" + line.replace("'", "'\\''") + "'"; + String cmd = "umask 077; mkdir -p ~/.ssh && touch ~/.ssh/authorized_keys && " + + "{ grep -qxF " + q + " ~/.ssh/authorized_keys || echo " + q + + " >> ~/.ssh/authorized_keys; }"; + try (Session s = c.startSession()) { + Session.Command run = s.exec(cmd); + run.join(10, TimeUnit.SECONDS); + Integer st = run.getExitStatus(); + if (st == null || st != 0) + localFromAnyThread("\033[33m公钥写入失败(exit " + st + "),下次仍需密码。\033[0m\r\n"); + else + localFromAnyThread("\033[2m已写入公钥,以后自动登录。\033[0m\r\n"); + } + } + + /** Mirrors ~/.termux/{font.ttf,colors.properties,termux.properties} when they change. */ + private void syncAppearance(SSHClient c) { + File dir = Appearance.dir(app.getFilesDir()); + if (!dir.isDirectory() && !dir.mkdirs()) return; + boolean changed = false; + try (SFTPClient sftp = c.newSFTPClient()) { + for (String name : Appearance.FILES) { + String remote = ".termux/" + name; + File local = new File(dir, name); + FileAttributes a = sftp.statExistence(remote); + if (a != null && a.getSize() > 32L << 20) a = null; + String stamp = a == null ? null : a.getSize() + ":" + a.getMtime(); + String key = "sync." + name; + if (Objects.equals(stamp, prefs.getString(key, null)) && (a == null) != local.isFile()) + continue; + if (a == null) { + if (local.exists() && !local.delete()) continue; + } else { + File part = new File(dir, name + ".part"); + sftp.get(remote, part.getPath()); + if (!part.renameTo(local)) continue; + } + prefs.edit().putString(key, stamp).apply(); + changed = true; + } + } catch (Exception e) { + Log.w(TAG, "appearance sync failed", e); + } + if (changed) main.post(() -> { + if (host != null) host.onAppearanceSynced(); + }); + } + + void send(byte[] data, int offset, int count) { + byte[] copy = new byte[count]; + System.arraycopy(data, offset, copy, 0, count); + try { + io.execute(() -> { + try { + out.write(copy); + out.flush(); + } catch (IOException e) { + Log.w(TAG, "write failed", e); + } + }); + } catch (java.util.concurrent.RejectedExecutionException ignored) { + } + } + + void resize(int c, int r, int w, int h) { + if (shell == null) return; + try { + io.execute(() -> { + try { + shell.changeWindowDimensions(c, r, w, h); + } catch (Exception e) { + Log.w(TAG, "resize failed", e); + } + }); + } catch (java.util.concurrent.RejectedExecutionException ignored) { + } + } + + // HostKeyVerifier: trust on first use, ask when the key changes. + + @Override + public boolean verify(String hostname, int port, PublicKey key) { + String k = "hostkey." + hostname + ":" + port; + String fp = fingerprint(key); + String old = prefs.getString(k, null); + if (fp.equals(old)) return true; + if (old != null) { + String m = "Termux 的主机密钥变了(重装 Termux 或重新生成 ssh_host_* 会这样)。\n\n旧:" + old + + "\n新:" + fp + "\n\n信任新密钥?"; + Boolean ok; + try { + ok = askOnMain(reply -> host.confirm(m, reply)); + } catch (InterruptedException e) { + return false; + } + if (ok == null || !ok) return false; + } + prefs.edit().putString(k, fp).apply(); + return true; + } + + @Override + public List findExistingAlgorithms(String hostname, int port) { + return Collections.emptyList(); + } + } + + private void finished(String err) { + conn = null; + connected = false; + local("\r\n\033[33m[" + (err == null ? "连接已关闭" : err) + "] 按 Enter 重新连接\033[0m\r\n"); + } +} diff --git a/app/src/main/java/xyz/whcself/whcshell/TermView.java b/app/src/main/java/xyz/whcself/whcshell/TermView.java new file mode 100644 index 0000000..5500972 --- /dev/null +++ b/app/src/main/java/xyz/whcself/whcshell/TermView.java @@ -0,0 +1,730 @@ +package xyz.whcself.whcshell; + +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Typeface; +import android.os.SystemClock; +import android.text.Editable; +import android.text.InputType; +import android.view.ContextMenu; +import android.view.GestureDetector; +import android.view.InputDevice; +import android.view.KeyCharacterMap; +import android.view.KeyEvent; +import android.view.MotionEvent; +import android.view.PointerIcon; +import android.view.View; +import android.view.inputmethod.BaseInputConnection; +import android.view.inputmethod.EditorInfo; +import android.view.inputmethod.InputConnection; +import android.view.inputmethod.InputMethodManager; + +import com.termux.terminal.KeyHandler; +import com.termux.terminal.TerminalBuffer; +import com.termux.terminal.TerminalEmulator; +import com.termux.terminal.TerminalRow; +import com.termux.terminal.TextStyle; +import com.termux.view.TerminalRenderer; + +import java.nio.charset.StandardCharsets; + +/** Draws a {@link TermSession} and turns keyboard, IME and pointer input into terminal input. */ +public final class TermView extends View { + + public interface Actions { + void paste(); + + /** Pastes the last mouse selection (X11-style primary selection), or the clipboard. */ + void pastePrimary(); + + void copy(String text); + + void setPrimary(String text); + + void zoom(int delta); + + void toggleFullscreen(); + + /** Fills the right-click / long-press menu. */ + void buildMenu(ContextMenu menu, String selection); + } + + private static final long MULTI_CLICK_MS = 400; + private static final String WORD_CHARS = "_-.~/:@%+=?&#$"; + + private TermSession session; + private Actions actions; + private TerminalRenderer renderer; + private Typeface typeface = Typeface.MONOSPACE; + private int textSizePx = 28; + private int topRow; + private float scrollAccum; + private int combiningAccent; + private final GestureDetector gestures; + private final KeyEncoder.Modes modes = new KeyEncoder.Modes(); + + // Selection, in external buffer coordinates (negative rows are scrollback), inclusive. + private boolean selActive; + private int selX1, selY1, selX2, selY2; + private int anchorX, anchorY, selMode; + private boolean selDragging; + private long lastClickTime; + private int lastClickX = -1, lastClickY, clickCount; + + // Mouse button state as last seen, and whether the current gesture goes to the application. + private int mouseButtons; + private boolean mouseReporting; + private boolean implicitPrimary; + private int lastMouseCol = -1, lastMouseRow = -1; + + private int blinkMs; + private boolean blinkOn = true; + private final Runnable blink = new Runnable() { + @Override + public void run() { + TerminalEmulator e = emulator(); + if (e == null || blinkMs == 0) return; + blinkOn = !blinkOn; + e.setCursorBlinkState(blinkOn); + invalidate(); + postDelayed(this, blinkMs); + } + }; + + public TermView(Context context) { + super(context); + setFocusable(true); + setFocusableInTouchMode(true); + setPointerIcon(PointerIcon.getSystemIcon(context, PointerIcon.TYPE_TEXT)); + setOnCreateContextMenuListener((menu, v, info) -> { + if (actions != null) actions.buildMenu(menu, selectedText()); + }); + gestures = new GestureDetector(context, new GestureDetector.SimpleOnGestureListener() { + @Override + public boolean onDown(MotionEvent e) { + return true; + } + + @Override + public boolean onScroll(MotionEvent e1, MotionEvent e2, float dx, float dy) { + scrollAccum += dy / renderer.getFontLineSpacing(); + int lines = (int) scrollAccum; + if (lines != 0) { + scrollAccum -= lines; + scrollLines(lines, e2); + } + return true; + } + + @Override + public boolean onSingleTapUp(MotionEvent e) { + clearSelection(); + showSoftKeyboard(); + return true; + } + + @Override + public void onLongPress(MotionEvent e) { + if (emulator() == null) return; + selMode = 2; + anchorX = column(e); + anchorY = bufferRow(e); + extendSelection(anchorX, anchorY); + showContextMenu(e.getX(), e.getY()); + } + }); + } + + public void attach(TermSession s, Actions a) { + session = s; + actions = a; + updateSize(); + invalidate(); + } + + public void setFont(Typeface tf, int px) { + typeface = tf; + textSizePx = px; + renderer = new TerminalRenderer(px, tf); + updateSize(); + invalidate(); + } + + public void setTextSizePx(int px) { + setFont(typeface, px); + } + + public void setBlink(int ms) { + blinkMs = ms; + removeCallbacks(blink); + TerminalEmulator e = emulator(); + if (e != null) { + e.setCursorBlinkingEnabled(ms > 0); + e.setCursorBlinkState(true); + } + blinkOn = true; + if (ms > 0 && hasWindowFocus()) postDelayed(blink, ms); + invalidate(); + } + + private void restartBlink() { + if (blinkMs == 0) return; + TerminalEmulator e = emulator(); + if (e != null) e.setCursorBlinkState(true); + blinkOn = true; + removeCallbacks(blink); + postDelayed(blink, blinkMs); + } + + public void showSoftKeyboard() { + requestFocus(); + InputMethodManager imm = getContext().getSystemService(InputMethodManager.class); + if (imm != null) imm.showSoftInput(this, 0); + } + + /** Called after new output; keeps a scrolled-back view and the selection anchored to the text. */ + public void onScreenUpdated() { + TerminalEmulator e = emulator(); + if (e == null) return; + int scrolled = e.getScrollCounter(); + e.clearScrollCounter(); + if (scrolled != 0) { + int min = -e.getScreen().getActiveTranscriptRows(); + if (topRow < 0) topRow = Math.max(topRow - scrolled, min); + if (selActive || selDragging) { + selY1 -= scrolled; + selY2 -= scrolled; + anchorY -= scrolled; + if (selY1 < min) clearSelection(); + } + } + invalidate(); + } + + private TerminalEmulator emulator() { + return session == null ? null : session.emulator(); + } + + @Override + protected void onSizeChanged(int w, int h, int oldw, int oldh) { + updateSize(); + } + + @Override + public void setPadding(int left, int top, int right, int bottom) { + super.setPadding(left, top, right, bottom); + updateSize(); + } + + private void updateSize() { + int w = getWidth() - getPaddingLeft() - getPaddingRight(); + int h = getHeight() - getPaddingTop() - getPaddingBottom(); + if (session == null || renderer == null || w <= 0 || h <= 0) return; + float cw = renderer.getFontWidth(); + int ch = renderer.getFontLineSpacing(); + int cols = Math.max(4, (int) (w / cw)); + int rows = Math.max(4, (h - renderer.getFontLineSpacingAndAscent()) / ch); + TerminalEmulator e = emulator(); + if (e == null || e.mColumns != cols || e.mRows != rows) { + clearSelection(); + topRow = 0; + } + session.resize(cols, rows, (int) cw, ch); + if (e == null) setBlink(blinkMs); + } + + @Override + protected void onDraw(Canvas canvas) { + TerminalEmulator e = emulator(); + if (e == null || renderer == null) { + canvas.drawColor(0xff000000); + return; + } + canvas.drawColor(e.mColors.mCurrentColors[TextStyle.COLOR_INDEX_BACKGROUND]); + canvas.save(); + canvas.translate(getPaddingLeft(), getPaddingTop()); + if (selActive) renderer.render(e, canvas, topRow, selY1, selY2, selX1, selX2); + else renderer.render(e, canvas, topRow, -1, -1, -1, -1); + canvas.restore(); + } + + @Override + public void onWindowFocusChanged(boolean focused) { + super.onWindowFocusChanged(focused); + TerminalEmulator e = emulator(); + if (e != null && e.isFocusReportingActive() && session.isConnected()) + send(focused ? "\033[I" : "\033[O"); + if (focused) restartBlink(); + else removeCallbacks(blink); + } + + // ---- output helpers + + private void send(String s) { + if (session == null || s == null || s.isEmpty()) return; + byte[] b = s.getBytes(StandardCharsets.UTF_8); + session.write(b, 0, b.length); + } + + /** Sends user input: snaps back to the bottom and drops the selection. */ + private void sendInput(String s) { + if (s == null || s.isEmpty()) return; + if (topRow != 0) topRow = 0; + clearSelection(); + restartBlink(); + send(s); + invalidate(); + } + + // ---- geometry + + private int column(MotionEvent ev) { + int cols = emulator().mColumns; + int c = (int) ((ev.getX() - getPaddingLeft()) / renderer.getFontWidth()); + return Math.max(0, Math.min(cols - 1, c)); + } + + /** Visible row, 0-based. */ + private int row(MotionEvent ev) { + int rows = emulator().mRows; + int r = (int) Math.floor((ev.getY() - getPaddingTop()) / renderer.getFontLineSpacing()); + return Math.max(0, Math.min(rows - 1, r)); + } + + private int bufferRow(MotionEvent ev) { + return row(ev) + topRow; + } + + // ---- selection + + public String selectedText() { + TerminalEmulator e = emulator(); + if (!selActive || e == null) return null; + String s = e.getSelectedText(selX1, selY1, selX2, selY2); + return s == null || s.isEmpty() ? null : s; + } + + public void clearSelection() { + if (selActive || selDragging) { + selActive = false; + selDragging = false; + invalidate(); + } + } + + public void selectAll() { + TerminalEmulator e = emulator(); + if (e == null) return; + selActive = true; + selX1 = 0; + selY1 = -e.getScreen().getActiveTranscriptRows(); + selX2 = e.mColumns - 1; + selY2 = e.mRows - 1; + invalidate(); + } + + private static boolean before(int x1, int y1, int x2, int y2) { + return y1 < y2 || (y1 == y2 && x1 <= x2); + } + + private int codePointAt(TerminalBuffer screen, int col, int row) { + TerminalRow line = screen.allocateFullLineIfNecessary(screen.externalToInternalRow(row)); + int i = line.findStartOfColumn(col); + if (i >= line.getSpaceUsed()) return ' '; + return Character.codePointAt(line.mText, i); + } + + private static boolean isWordChar(int cp) { + return Character.isLetterOrDigit(cp) || WORD_CHARS.indexOf(cp) >= 0; + } + + /** Selects from the anchor to (x, y), snapping to words or lines depending on the click count. */ + private void extendSelection(int x, int y) { + TerminalEmulator e = emulator(); + TerminalBuffer screen = e.getScreen(); + int cols = e.mColumns; + int ax = anchorX, ay = anchorY; + int x1, y1, x2, y2; + if (before(ax, ay, x, y)) { + x1 = ax; y1 = ay; x2 = x; y2 = y; + } else { + x1 = x; y1 = y; x2 = ax; y2 = ay; + } + if (selMode == 2) { + if (isWordChar(codePointAt(screen, x1, y1))) + while (x1 > 0 && isWordChar(codePointAt(screen, x1 - 1, y1))) x1--; + if (isWordChar(codePointAt(screen, x2, y2))) + while (x2 < cols - 1 && isWordChar(codePointAt(screen, x2 + 1, y2))) x2++; + } else if (selMode == 3) { + int min = -screen.getActiveTranscriptRows(); + while (y1 > min && screen.getLineWrap(y1 - 1)) y1--; + while (y2 < e.mRows - 1 && screen.getLineWrap(y2)) y2++; + x1 = 0; + x2 = cols - 1; + } + selX1 = x1; + selY1 = y1; + selX2 = x2; + selY2 = y2; + selActive = true; + invalidate(); + } + + // ---- scrolling + + /** Positive {@code lines} scrolls towards newer output. */ + private void scrollLines(int lines, MotionEvent ev) { + TerminalEmulator e = emulator(); + if (e == null || lines == 0) return; + boolean up = lines < 0; + int n = Math.abs(lines); + boolean shift = ev != null && (ev.getMetaState() & KeyEvent.META_SHIFT_ON) != 0; + if (e.isMouseTrackingActive() && ev != null && !shift) { + int col = column(ev) + 1, row = row(ev) + 1; + int button = (up ? TerminalEmulator.MOUSE_WHEELUP_BUTTON + : TerminalEmulator.MOUSE_WHEELDOWN_BUTTON) | mouseMods(ev); + for (int i = 0; i < n; i++) e.sendMouseEvent(button, col, row, true); + } else if (e.isAlternateBufferActive() && !shift) { + String key = KeyHandler.getCode(up ? KeyEvent.KEYCODE_DPAD_UP : KeyEvent.KEYCODE_DPAD_DOWN, + 0, e.isCursorKeysApplicationMode(), e.isKeypadApplicationMode()); + for (int i = 0; i < n; i++) send(key); + } else { + scrollTranscript(lines); + } + } + + private void scrollTranscript(int lines) { + TerminalEmulator e = emulator(); + if (e == null) return; + int min = -e.getScreen().getActiveTranscriptRows(); + int t = Math.min(0, Math.max(min, topRow + lines)); + if (t != topRow) { + topRow = t; + invalidate(); + } + } + + // ---- pointer input + + @Override + public PointerIcon onResolvePointerIcon(MotionEvent event, int pointerIndex) { + return PointerIcon.getSystemIcon(getContext(), PointerIcon.TYPE_TEXT); + } + + @Override + public boolean onGenericMotionEvent(MotionEvent ev) { + if (emulator() == null || renderer == null) return super.onGenericMotionEvent(ev); + if (ev.getAction() == MotionEvent.ACTION_SCROLL + && ev.isFromSource(InputDevice.SOURCE_CLASS_POINTER)) { + float v = ev.getAxisValue(MotionEvent.AXIS_VSCROLL); + if ((ev.getMetaState() & KeyEvent.META_CTRL_ON) != 0) { + if (actions != null && v != 0) actions.zoom(v > 0 ? 1 : -1); + return true; + } + scrollAccum -= v * 3; + int lines = (int) scrollAccum; + if (lines != 0) { + scrollAccum -= lines; + scrollLines(lines, ev); + } + return true; + } + if (ev.isFromSource(InputDevice.SOURCE_MOUSE)) return onMouse(ev); + return super.onGenericMotionEvent(ev); + } + + @Override + public boolean onTouchEvent(MotionEvent ev) { + if (emulator() == null || renderer == null) return true; + if (ev.isFromSource(InputDevice.SOURCE_MOUSE)) return onMouse(ev); + return gestures.onTouchEvent(ev); + } + + private static int mouseMods(MotionEvent ev) { + int meta = ev.getMetaState(), m = 0; + if ((meta & KeyEvent.META_SHIFT_ON) != 0) m |= 4; + if ((meta & KeyEvent.META_ALT_ON) != 0) m |= 8; + if ((meta & KeyEvent.META_CTRL_ON) != 0) m |= 16; + return m; + } + + private static final int[][] BUTTONS = { + {MotionEvent.BUTTON_PRIMARY, TerminalEmulator.MOUSE_LEFT_BUTTON}, + {MotionEvent.BUTTON_TERTIARY, 1}, + {MotionEvent.BUTTON_SECONDARY, 2}, + }; + + /** + * Mouse events arrive through both onTouchEvent and onGenericMotionEvent depending on the + * button, so presses and releases are derived from changes in the button state. + */ + private boolean onMouse(MotionEvent ev) { + TerminalEmulator e = emulator(); + int action = ev.getActionMasked(); + int state = ev.getButtonState() + & (MotionEvent.BUTTON_PRIMARY | MotionEvent.BUTTON_SECONDARY | MotionEvent.BUTTON_TERTIARY); + // Touchpad taps and injected events can come as DOWN/UP without any button state. + if (action == MotionEvent.ACTION_DOWN && state == 0) implicitPrimary = true; + if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_CANCEL) implicitPrimary = false; + if (implicitPrimary) state |= MotionEvent.BUTTON_PRIMARY; + if (action == MotionEvent.ACTION_CANCEL) state = 0; + int pressed = state & ~mouseButtons, released = mouseButtons & ~state; + int held = mouseButtons; + mouseButtons = state; + int col = column(ev), row = row(ev); + boolean shift = (ev.getMetaState() & KeyEvent.META_SHIFT_ON) != 0; + boolean tracking = e.isMouseTrackingActive() && !shift && session.isConnected(); + + if (pressed != 0 && held == 0) { + mouseReporting = tracking; + requestFocus(); + } + boolean report = (held != 0 || pressed != 0) ? mouseReporting : tracking; + + if (report) { + int mods = mouseMods(ev); + for (int[] b : BUTTONS) + if ((released & b[0]) != 0) e.sendMouseEvent(b[1] | mods, col + 1, row + 1, false); + for (int[] b : BUTTONS) + if ((pressed & b[0]) != 0) e.sendMouseEvent(b[1] | mods, col + 1, row + 1, true); + boolean moved = col != lastMouseCol || row != lastMouseRow; + if (moved && pressed == 0 && released == 0 && (action == MotionEvent.ACTION_MOVE + || action == MotionEvent.ACTION_HOVER_MOVE)) { + int b = 3; + for (int[] bb : BUTTONS) + if ((state & bb[0]) != 0) { + b = bb[1]; + break; + } + e.sendMouseEvent((TerminalEmulator.MOUSE_LEFT_BUTTON_MOVED + b) | mods, col + 1, row + 1, true); + } + lastMouseCol = col; + lastMouseRow = row; + return true; + } + lastMouseCol = col; + lastMouseRow = row; + + if ((pressed & MotionEvent.BUTTON_PRIMARY) != 0) { + long now = SystemClock.uptimeMillis(); + int y = row + topRow; + if (now - lastClickTime < MULTI_CLICK_MS && Math.abs(col - lastClickX) <= 1 && y == lastClickY) + clickCount = clickCount % 3 + 1; + else + clickCount = 1; + lastClickTime = now; + lastClickX = col; + lastClickY = y; + clearSelection(); + selMode = clickCount; + anchorX = col; + anchorY = y; + selDragging = true; + if (selMode > 1) extendSelection(col, y); + } else if (selDragging && (state & MotionEvent.BUTTON_PRIMARY) != 0 + && action == MotionEvent.ACTION_MOVE) { + float y = ev.getY(); + if (y < getPaddingTop()) scrollTranscript(-1); + else if (y > getHeight() - getPaddingBottom()) scrollTranscript(1); + extendSelection(col, row + topRow); + } + if ((released & MotionEvent.BUTTON_PRIMARY) != 0 && selDragging) { + selDragging = false; + String s = selectedText(); + if (s != null && actions != null) actions.setPrimary(s); + } + if ((pressed & MotionEvent.BUTTON_TERTIARY) != 0 && actions != null) actions.pastePrimary(); + if ((pressed & MotionEvent.BUTTON_SECONDARY) != 0) showContextMenu(ev.getX(), ev.getY()); + return true; + } + + // ---- keyboard + + @Override + public boolean onKeyDown(int keyCode, KeyEvent ev) { + if (session == null) return super.onKeyDown(keyCode, ev); + + if (keyCode == KeyEvent.KEYCODE_BACK) { + // Mouse right-click and the navigation bar both arrive as BACK; only a real keyboard means Esc. + if (ev.isFromSource(InputDevice.SOURCE_MOUSE)) return true; + if (isVirtual(ev)) return super.onKeyDown(keyCode, ev); + keyCode = KeyEvent.KEYCODE_ESCAPE; + } + + if (ev.isMetaPressed()) return super.onKeyDown(keyCode, ev); + + boolean ctrl = ev.isCtrlPressed(), alt = ev.isAltPressed(), shift = ev.isShiftPressed(); + if (handleShortcut(keyCode, ctrl, alt, shift)) return true; + + if (!session.isConnected()) { + if (!session.isConnecting() && (keyCode == KeyEvent.KEYCODE_ENTER + || keyCode == KeyEvent.KEYCODE_NUMPAD_ENTER)) { + session.connect(); + return true; + } + return !KeyEvent.isModifierKey(keyCode) || super.onKeyDown(keyCode, ev); + } + + int meta = ev.getMetaState(); + int text = ev.getUnicodeChar(meta & ~(KeyEvent.META_CTRL_MASK | KeyEvent.META_ALT_MASK)); + int base = ev.getUnicodeChar(0); + if ((text & KeyCharacterMap.COMBINING_ACCENT) != 0) { + combiningAccent = text & KeyCharacterMap.COMBINING_ACCENT_MASK; + return true; + } + if (combiningAccent != 0 && text != 0) { + int composed = KeyCharacterMap.getDeadChar(combiningAccent, text); + combiningAccent = 0; + if (composed != 0) text = base = composed; + } + if ((base & KeyCharacterMap.COMBINING_ACCENT) != 0) base = 0; + + TerminalEmulator e = emulator(); + modes.cursorApp = e.isCursorKeysApplicationMode(); + modes.keypadApp = e.isKeypadApplicationMode(); + modes.kittyFlags = e.getKittyKeyboardFlags(); + modes.modifyOtherKeys = e.getModifyOtherKeys(); + int mods = (shift ? KeyEncoder.SHIFT : 0) | (alt ? KeyEncoder.ALT : 0) | (ctrl ? KeyEncoder.CTRL : 0); + String out = KeyEncoder.encode(keyCode, base, text, mods, ev.isNumLockOn(), modes); + if (out == null) return KeyEvent.isModifierKey(keyCode) ? super.onKeyDown(keyCode, ev) : false; + sendInput(out); + return true; + } + + /** Terminal-level shortcuts that never reach the application. */ + private boolean handleShortcut(int keyCode, boolean ctrl, boolean alt, boolean shift) { + if (actions == null) return false; + if (keyCode == KeyEvent.KEYCODE_F11 && !ctrl && !alt && !shift) { + actions.toggleFullscreen(); + return true; + } + TerminalEmulator e = emulator(); + if (shift && !ctrl && !alt && e != null && !e.isAlternateBufferActive()) { + if (keyCode == KeyEvent.KEYCODE_PAGE_UP) { + scrollTranscript(-(e.mRows - 1)); + return true; + } else if (keyCode == KeyEvent.KEYCODE_PAGE_DOWN) { + scrollTranscript(e.mRows - 1); + return true; + } + } + if (!ctrl || !shift || alt) return false; + switch (keyCode) { + case KeyEvent.KEYCODE_C: { + String s = selectedText(); + if (s != null) actions.copy(s); + return true; + } + case KeyEvent.KEYCODE_V: + actions.paste(); + return true; + case KeyEvent.KEYCODE_A: + selectAll(); + return true; + case KeyEvent.KEYCODE_EQUALS: + case KeyEvent.KEYCODE_PLUS: + case KeyEvent.KEYCODE_NUMPAD_ADD: + actions.zoom(1); + return true; + case KeyEvent.KEYCODE_MINUS: + case KeyEvent.KEYCODE_NUMPAD_SUBTRACT: + actions.zoom(-1); + return true; + case KeyEvent.KEYCODE_0: + case KeyEvent.KEYCODE_NUMPAD_0: + actions.zoom(0); + return true; + case KeyEvent.KEYCODE_PAGE_UP: + if (e != null) scrollTranscript(-(e.mRows - 1)); + return true; + case KeyEvent.KEYCODE_PAGE_DOWN: + if (e != null) scrollTranscript(e.mRows - 1); + return true; + case KeyEvent.KEYCODE_MOVE_HOME: + if (e != null) scrollTranscript(-e.getScreen().getActiveTranscriptRows()); + return true; + case KeyEvent.KEYCODE_MOVE_END: + scrollTranscript(Integer.MAX_VALUE / 2); + return true; + default: + return false; + } + } + + @Override + public boolean onKeyUp(int keyCode, KeyEvent ev) { + if (keyCode == KeyEvent.KEYCODE_BACK && (ev.isFromSource(InputDevice.SOURCE_MOUSE) || !isVirtual(ev))) + return true; + return super.onKeyUp(keyCode, ev); + } + + @Override + @SuppressWarnings("deprecation") + public boolean onKeyMultiple(int keyCode, int count, KeyEvent ev) { + if (keyCode == KeyEvent.KEYCODE_UNKNOWN && ev.getCharacters() != null) { + sendInput(ev.getCharacters()); + return true; + } + return super.onKeyMultiple(keyCode, count, ev); + } + + private static boolean isVirtual(KeyEvent ev) { + InputDevice d = ev.getDevice(); + return d == null || d.isVirtual(); + } + + // ---- IME + + @Override + public boolean onCheckIsTextEditor() { + return true; + } + + @Override + public InputConnection onCreateInputConnection(EditorInfo out) { + // A plain text field (not "visible password") so that Chinese IMEs keep their candidate UI. + out.inputType = InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS; + out.imeOptions = EditorInfo.IME_FLAG_NO_FULLSCREEN | EditorInfo.IME_FLAG_NO_EXTRACT_UI + | EditorInfo.IME_ACTION_NONE; + return new BaseInputConnection(this, true) { + @Override + public boolean commitText(CharSequence text, int newCursorPosition) { + super.commitText(text, newCursorPosition); + flush(); + return true; + } + + @Override + public boolean finishComposingText() { + super.finishComposingText(); + flush(); + return true; + } + + @Override + public boolean deleteSurroundingText(int before, int after) { + Editable ed = getEditable(); + if (ed == null || ed.length() == 0) { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < before; i++) sb.append('\u007f'); + for (int i = 0; i < after; i++) sb.append("\033[3~"); + if (session != null && session.isConnected()) sendInput(sb.toString()); + return true; + } + return super.deleteSurroundingText(before, after); + } + + private void flush() { + Editable ed = getEditable(); + if (ed == null || ed.length() == 0) return; + String s = ed.toString().replace('\n', '\r'); + ed.clear(); + if (session != null && !session.isConnected()) { + if (s.contains("\r") && !session.isConnecting()) session.connect(); + return; + } + sendInput(s); + } + }; + } +} diff --git a/app/src/main/java/xyz/whcself/whcshell/WhcApp.java b/app/src/main/java/xyz/whcself/whcshell/WhcApp.java new file mode 100644 index 0000000..4928705 --- /dev/null +++ b/app/src/main/java/xyz/whcself/whcshell/WhcApp.java @@ -0,0 +1,26 @@ +package xyz.whcself.whcshell; + +import android.app.Application; + +import org.bouncycastle.jce.provider.BouncyCastleProvider; + +import java.security.Security; + +public final class WhcApp extends Application { + + private TermSession session; + + @Override + public void onCreate() { + super.onCreate(); + // Android ships a trimmed "BC" provider without Ed25519/X25519; sshj needs the full one. + Security.removeProvider(BouncyCastleProvider.PROVIDER_NAME); + Security.insertProviderAt(new BouncyCastleProvider(), 1); + } + + /** The session outlives the activity so that configuration changes never drop the connection. */ + public TermSession session() { + if (session == null) session = new TermSession(this); + return session; + } +} diff --git a/app/src/main/res/drawable/ic_launcher_fg.xml b/app/src/main/res/drawable/ic_launcher_fg.xml new file mode 100644 index 0000000..d6c226f --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_fg.xml @@ -0,0 +1,19 @@ + + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..7cc4a47 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..e5451aa --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + + WHC Shell + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..2eada7c --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,9 @@ + + + + diff --git a/app/src/test/java/com/termux/terminal/WhcEmulatorTest.java b/app/src/test/java/com/termux/terminal/WhcEmulatorTest.java new file mode 100644 index 0000000..2300c66 --- /dev/null +++ b/app/src/test/java/com/termux/terminal/WhcEmulatorTest.java @@ -0,0 +1,118 @@ +package com.termux.terminal; + +import static org.junit.Assert.assertEquals; + +import org.junit.Test; + +import java.nio.charset.StandardCharsets; + +/** Tests for the whc-shell additions to the emulator. */ +public class WhcEmulatorTest { + + private final StringBuilder replies = new StringBuilder(); + private final TerminalEmulator emu = new TerminalEmulator(new TerminalOutput() { + @Override + public void write(byte[] data, int offset, int count) { + replies.append(new String(data, offset, count, StandardCharsets.UTF_8)); + } + + @Override + public void titleChanged(String oldTitle, String newTitle) { + } + + @Override + public void onCopyTextToClipboard(String text) { + } + + @Override + public void onPasteTextFromClipboard() { + } + + @Override + public void onBell() { + } + + @Override + public void onColorsChanged() { + } + }, 80, 24, 10, 20, 100, null); + + private String feed(String s) { + replies.setLength(0); + byte[] b = s.getBytes(StandardCharsets.UTF_8); + emu.append(b, b.length); + return replies.toString(); + } + + @Test + public void kittyStack() { + assertEquals("\033[?0u", feed("\033[?u")); + feed("\033[>1u"); + assertEquals(1, emu.getKittyKeyboardFlags()); + assertEquals("\033[?1u", feed("\033[?u")); + feed("\033[>31u"); + assertEquals("unsupported bits are dropped", 1, emu.getKittyKeyboardFlags()); + feed("\033[1u"); + feed("\033[?1049h"); + assertEquals(0, emu.getKittyKeyboardFlags()); + feed("\033[>1u"); + assertEquals(1, emu.getKittyKeyboardFlags()); + feed("\033[4;2m"); + assertEquals(2, emu.getModifyOtherKeys()); + feed("\033[>4;1m"); + assertEquals(1, emu.getModifyOtherKeys()); + feed("\033[>4m"); + assertEquals(0, emu.getModifyOtherKeys()); + } + + @Test + public void mouseModes() { + feed("\033[?1002h\033[?1006h"); + replies.setLength(0); + emu.sendMouseEvent(35, 5, 6, true); + assertEquals("1002 ignores hover", "", replies.toString()); + emu.sendMouseEvent(32, 5, 6, true); + assertEquals("\033[<32;5;6M", replies.toString()); + + feed("\033[?1003h"); + replies.setLength(0); + emu.sendMouseEvent(35, 7, 8, true); + assertEquals("\033[<35;7;8M", replies.toString()); + replies.setLength(0); + emu.sendMouseEvent(TerminalEmulator.MOUSE_WHEELUP_BUTTON, 1, 1, true); + assertEquals("\033[<64;1;1M", replies.toString()); + + feed("\033[?1000h"); + replies.setLength(0); + emu.sendMouseEvent(32, 5, 6, true); + assertEquals("modes are exclusive", "", replies.toString()); + assertEquals(false, emu.isMouseHoverTrackingActive()); + + feed("\033[?1004h"); + assertEquals(true, emu.isFocusReportingActive()); + } +} diff --git a/app/src/test/java/xyz/whcself/whcshell/KeyEncoderTest.java b/app/src/test/java/xyz/whcself/whcshell/KeyEncoderTest.java new file mode 100644 index 0000000..8d0f03f --- /dev/null +++ b/app/src/test/java/xyz/whcself/whcshell/KeyEncoderTest.java @@ -0,0 +1,106 @@ +package xyz.whcself.whcshell; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static xyz.whcself.whcshell.KeyEncoder.ALT; +import static xyz.whcself.whcshell.KeyEncoder.CTRL; +import static xyz.whcself.whcshell.KeyEncoder.SHIFT; + +import android.view.KeyEvent; + +import org.junit.Test; + +public class KeyEncoderTest { + + private static KeyEncoder.Modes modes(int kitty, int mok) { + KeyEncoder.Modes m = new KeyEncoder.Modes(); + m.kittyFlags = kitty; + m.modifyOtherKeys = mok; + return m; + } + + private static String key(int code, int base, int text, int mods, KeyEncoder.Modes m) { + return KeyEncoder.encode(code, base, text, mods, false, m); + } + + @Test + public void legacy() { + KeyEncoder.Modes m = modes(0, 0); + assertEquals("a", key(KeyEvent.KEYCODE_A, 'a', 'a', 0, m)); + assertEquals("A", key(KeyEvent.KEYCODE_A, 'a', 'A', SHIFT, m)); + assertEquals("\u0001", key(KeyEvent.KEYCODE_A, 'a', 'a', CTRL, m)); + assertEquals("\u0001", key(KeyEvent.KEYCODE_A, 'a', 'A', CTRL | SHIFT, m)); + assertEquals("\033b", key(KeyEvent.KEYCODE_B, 'b', 'b', ALT, m)); + assertEquals("\033\u0002", key(KeyEvent.KEYCODE_B, 'b', 'b', ALT | CTRL, m)); + assertEquals("\0", key(KeyEvent.KEYCODE_SPACE, ' ', ' ', CTRL, m)); + assertEquals(" ", key(KeyEvent.KEYCODE_SPACE, ' ', ' ', SHIFT, m)); + assertEquals("\033", key(KeyEvent.KEYCODE_ESCAPE, 0, 0, 0, m)); + assertEquals("\r", key(KeyEvent.KEYCODE_ENTER, 0, 0, 0, m)); + assertEquals("\r", key(KeyEvent.KEYCODE_ENTER, 0, 0, SHIFT, m)); + assertEquals("\033\r", key(KeyEvent.KEYCODE_ENTER, 0, 0, ALT, m)); + assertEquals("\r", key(KeyEvent.KEYCODE_NUMPAD_ENTER, 0, 0, 0, m)); + assertEquals("\t", key(KeyEvent.KEYCODE_TAB, 0, 0, 0, m)); + assertEquals("\033[Z", key(KeyEvent.KEYCODE_TAB, 0, 0, SHIFT, m)); + assertEquals("\u007f", key(KeyEvent.KEYCODE_DEL, 0, 0, 0, m)); + assertEquals("\b", key(KeyEvent.KEYCODE_DEL, 0, 0, CTRL, m)); + assertEquals("\033\u007f", key(KeyEvent.KEYCODE_DEL, 0, 0, ALT, m)); + assertEquals("\033[1;5D", key(KeyEvent.KEYCODE_DPAD_LEFT, 0, 0, CTRL, m)); + assertEquals("\033[1;3C", key(KeyEvent.KEYCODE_DPAD_RIGHT, 0, 0, ALT, m)); + assertEquals("\033[3;5~", key(KeyEvent.KEYCODE_FORWARD_DEL, 0, 0, CTRL, m)); + assertEquals("\033[5;5~", key(KeyEvent.KEYCODE_PAGE_UP, 0, 0, CTRL, m)); + assertEquals("\033[6~", key(KeyEvent.KEYCODE_PAGE_DOWN, 0, 0, 0, m)); + assertEquals("\033[1;2P", key(KeyEvent.KEYCODE_F1, 0, 0, SHIFT, m)); + assertEquals("中", key(KeyEvent.KEYCODE_UNKNOWN, 0, '中', 0, m)); + assertNull(key(KeyEvent.KEYCODE_SHIFT_LEFT, 0, 0, SHIFT, m)); + } + + @Test + public void cursorApplicationMode() { + KeyEncoder.Modes m = modes(0, 0); + m.cursorApp = true; + assertEquals("\033OA", key(KeyEvent.KEYCODE_DPAD_UP, 0, 0, 0, m)); + assertEquals("\033[1;5A", key(KeyEvent.KEYCODE_DPAD_UP, 0, 0, CTRL, m)); + } + + @Test + public void kittyDisambiguate() { + KeyEncoder.Modes m = modes(1, 0); + assertEquals("a", key(KeyEvent.KEYCODE_A, 'a', 'a', 0, m)); + assertEquals("A", key(KeyEvent.KEYCODE_A, 'a', 'A', SHIFT, m)); + assertEquals("\033[97;5u", key(KeyEvent.KEYCODE_A, 'a', 'a', CTRL, m)); + assertEquals("\033[97;6u", key(KeyEvent.KEYCODE_A, 'a', 'A', CTRL | SHIFT, m)); + assertEquals("\033[98;3u", key(KeyEvent.KEYCODE_B, 'b', 'b', ALT, m)); + assertEquals("\033[105;5u", key(KeyEvent.KEYCODE_I, 'i', 'i', CTRL, m)); + assertEquals("\033[32;5u", key(KeyEvent.KEYCODE_SPACE, ' ', ' ', CTRL, m)); + assertEquals("\033[27u", key(KeyEvent.KEYCODE_ESCAPE, 0, 0, 0, m)); + assertEquals("\033[27;3u", key(KeyEvent.KEYCODE_ESCAPE, 0, 0, ALT, m)); + assertEquals("\r", key(KeyEvent.KEYCODE_ENTER, 0, 0, 0, m)); + assertEquals("\033[13;2u", key(KeyEvent.KEYCODE_ENTER, 0, 0, SHIFT, m)); + assertEquals("\033[13;5u", key(KeyEvent.KEYCODE_ENTER, 0, 0, CTRL, m)); + assertEquals("\t", key(KeyEvent.KEYCODE_TAB, 0, 0, 0, m)); + assertEquals("\033[9;2u", key(KeyEvent.KEYCODE_TAB, 0, 0, SHIFT, m)); + assertEquals("\u007f", key(KeyEvent.KEYCODE_DEL, 0, 0, 0, m)); + assertEquals("\033[127;5u", key(KeyEvent.KEYCODE_DEL, 0, 0, CTRL, m)); + assertEquals("\033[127;3u", key(KeyEvent.KEYCODE_DEL, 0, 0, ALT, m)); + assertEquals("\033[1;5D", key(KeyEvent.KEYCODE_DPAD_LEFT, 0, 0, CTRL, m)); + assertEquals("\033[3;5~", key(KeyEvent.KEYCODE_FORWARD_DEL, 0, 0, CTRL, m)); + } + + @Test + public void modifyOtherKeys() { + KeyEncoder.Modes m1 = modes(0, 1); + assertEquals("\u0001", key(KeyEvent.KEYCODE_A, 'a', 'a', CTRL, m1)); + assertEquals("\033[27;6;65~", key(KeyEvent.KEYCODE_A, 'a', 'A', CTRL | SHIFT, m1)); + assertEquals("\033[27;5;49~", key(KeyEvent.KEYCODE_1, '1', '1', CTRL, m1)); + assertEquals("\033[27;2;13~", key(KeyEvent.KEYCODE_ENTER, 0, 0, SHIFT, m1)); + assertEquals("\t", key(KeyEvent.KEYCODE_TAB, 0, 0, 0, m1)); + assertEquals("\033b", key(KeyEvent.KEYCODE_B, 'b', 'b', ALT, m1)); + + KeyEncoder.Modes m2 = modes(0, 2); + assertEquals("\033[27;5;97~", key(KeyEvent.KEYCODE_A, 'a', 'a', CTRL, m2)); + assertEquals("\033[27;3;98~", key(KeyEvent.KEYCODE_B, 'b', 'b', ALT, m2)); + assertEquals("\033[27;5;9~", key(KeyEvent.KEYCODE_TAB, 0, 0, CTRL, m2)); + assertEquals("\033[27;5;127~", key(KeyEvent.KEYCODE_DEL, 0, 0, CTRL, m2)); + assertEquals("a", key(KeyEvent.KEYCODE_A, 'a', 'a', 0, m2)); + } +} diff --git a/app/src/test/java/xyz/whcself/whcshell/SshKeysTest.java b/app/src/test/java/xyz/whcself/whcshell/SshKeysTest.java new file mode 100644 index 0000000..b8c4b0d --- /dev/null +++ b/app/src/test/java/xyz/whcself/whcshell/SshKeysTest.java @@ -0,0 +1,58 @@ +package xyz.whcself.whcshell; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.junit.Assume.assumeNotNull; + +import net.schmizz.sshj.SSHClient; +import net.schmizz.sshj.connection.channel.direct.Session; +import net.schmizz.sshj.transport.verification.PromiscuousVerifier; + +import org.bouncycastle.jce.provider.BouncyCastleProvider; +import org.junit.Test; + +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.security.Security; +import java.util.concurrent.TimeUnit; + +public class SshKeysTest { + + @Test + public void generatesOpenSshFiles() throws Exception { + File dir = Files.createTempDirectory("whc-keys").toFile(); + SshKeys k = SshKeys.ensure(dir, "test@host name"); + String pub = k.publicLine(); + assertTrue(pub, pub.matches("ssh-ed25519 [A-Za-z0-9+/=]+ test@host_name")); + String priv = new String(Files.readAllBytes(k.privateKey.toPath()), StandardCharsets.US_ASCII); + assertTrue(priv.startsWith("-----BEGIN OPENSSH PRIVATE KEY-----\n")); + assertEquals(pub, SshKeys.ensure(dir, "other").publicLine()); + } + + /** + * Needs an sshd on 127.0.0.1:$WHC_SSHD_PORT whose AuthorizedKeysFile is + * $WHC_SSHD_KEYDIR/authorized_keys. + */ + @Test + public void logsInWithGeneratedKey() throws Exception { + String port = System.getenv("WHC_SSHD_PORT"); + String dir = System.getenv("WHC_SSHD_KEYDIR"); + assumeNotNull(port, dir); + Security.insertProviderAt(new BouncyCastleProvider(), 1); + SshKeys k = SshKeys.ensure(new File(dir), "test"); + Files.write(new File(dir, "authorized_keys").toPath(), + (k.publicLine() + "\n").getBytes(StandardCharsets.US_ASCII)); + try (SSHClient c = new SSHClient()) { + c.addHostKeyVerifier(new PromiscuousVerifier()); + c.connect("127.0.0.1", Integer.parseInt(port)); + c.authPublickey(System.getProperty("user.name"), c.loadKeys(k.privateKey.getPath())); + try (Session s = c.startSession()) { + Session.Command cmd = s.exec("echo whc-ok"); + String out = new String(cmd.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + cmd.join(5, TimeUnit.SECONDS); + assertEquals("whc-ok\n", out); + } + } + } +} diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..3cd5a89 --- /dev/null +++ b/build.gradle @@ -0,0 +1,3 @@ +plugins { + id "com.android.application" version "8.10.1" apply false +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..18825f9 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,3 @@ +org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8 +android.useAndroidX=true +android.nonTransitiveRClass=true diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..aeaf93a --- /dev/null +++ b/settings.gradle @@ -0,0 +1,18 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "whc-shell" +include ":app" diff --git a/shell.nix b/shell.nix new file mode 100644 index 0000000..0ca4de4 --- /dev/null +++ b/shell.nix @@ -0,0 +1,27 @@ +{ pkgs ? import { + config = { + allowUnfree = true; + android_sdk.accept_license = true; + }; + } +}: + +let + buildTools = "35.0.0"; + android = pkgs.androidenv.composeAndroidPackages { + platformVersions = [ "35" ]; + buildToolsVersions = [ buildTools ]; + includeEmulator = false; + includeSystemImages = false; + includeNDK = false; + }; + sdk = "${android.androidsdk}/libexec/android-sdk"; +in +pkgs.mkShell { + packages = [ pkgs.jdk17 pkgs.gradle android.androidsdk pkgs.android-tools ]; + ANDROID_HOME = sdk; + ANDROID_SDK_ROOT = sdk; + JAVA_HOME = pkgs.jdk17.home; + # The aapt2 that Gradle fetches from Maven is not runnable on NixOS. + GRADLE_OPTS = "-Dorg.gradle.project.android.aapt2FromMavenOverride=${sdk}/build-tools/${buildTools}/aapt2"; +} diff --git a/third_party/Apache-2.0.txt b/third_party/Apache-2.0.txt new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/third_party/Apache-2.0.txt @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/third_party/termux-LICENSE.md b/third_party/termux-LICENSE.md new file mode 100644 index 0000000..36cd48f --- /dev/null +++ b/third_party/termux-LICENSE.md @@ -0,0 +1,6 @@ +The `termux/termux-app` repository is released under [GPLv3 only](https://www.gnu.org/licenses/gpl-3.0.html) license. + +### Exceptions + +- [Terminal Emulator for Android](https://github.com/jackpal/Android-Terminal-Emulator) code is used which is released under [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). Check [`terminal-view`](terminal-view) and [`terminal-emulator`](terminal-emulator) libraries. +- Check [`termux-shared/LICENSE.md`](termux-shared/LICENSE.md) for `termux-shared` library related exceptions.