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.
This commit is contained in:
WHC
2026-10-06 01:51:31 +08:00
commit a0023fd2ae
37 changed files with 7840 additions and 0 deletions
+51
View File
@@ -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"
}
+31
View File
@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="com.termux.permission.RUN_COMMAND" />
<queries>
<package android:name="com.termux" />
</queries>
<application
android:name=".WhcApp"
android:allowBackup="false"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="false"
android:theme="@style/Theme.WhcShell">
<activity
android:name=".MainActivity"
android:configChanges="keyboard|keyboardHidden|navigation|orientation|screenLayout|screenSize|smallestScreenSize|density|uiMode|fontScale|locale|layoutDirection"
android:exported="true"
android:launchMode="singleTask"
android:windowSoftInputMode="adjustResize|stateAlwaysHidden">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
@@ -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.
* <p/>
* 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;
}
}
@@ -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<String, Integer> 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 <kHome> keypad home key
// t_K3 <kPageUp> keypad page-up key
// t_K4 <kEnd> keypad end key
// t_K5 <kPageDown> 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 <Home> and <End> keys.
// normal vt100 ~
// <F1> t_k1 <Esc>[11~ <xF1> <Esc>OP *<xF1>-xterm*
// <F2> t_k2 <Esc>[12~ <xF2> <Esc>OQ *<xF2>-xterm*
// <F3> t_k3 <Esc>[13~ <xF3> <Esc>OR *<xF3>-xterm*
// <F4> t_k4 <Esc>[14~ <xF4> <Esc>OS *<xF4>-xterm*
// <Home> t_kh <Esc>[7~ <xHome> <Esc>OH *<xHome>-xterm*
// <End> t_@7 <Esc>[4~ <xEnd> <Esc>OF *<xEnd>-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;
}
}
@@ -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;
}
}
@@ -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.
* <p>
* 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.
*
* <pre>
* - 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 ]
* </pre>
*
* @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;
}
}
@@ -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<Object, Object> 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;
}
}
}
@@ -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
* <p/>
* 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:<red>/<green>/<blue> where <red>, <green>, <blue> := 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
));
}
}
File diff suppressed because it is too large Load Diff
@@ -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();
}
@@ -0,0 +1,283 @@
package com.termux.terminal;
import java.util.Arrays;
/**
* A row in a terminal, composed of a fixed number of cells.
* <p>
* 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];
}
}
@@ -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);
}
@@ -0,0 +1,90 @@
package com.termux.terminal;
/**
* <p>
* 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}.
* </p>
* <p>
* The bit layout is:
* </p>
* - 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.
* <p>
* 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.
* </p>
*/
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);
}
}
@@ -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;
}
}
@@ -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}.
* <p/>
* 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;
}
}
@@ -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;
}
}
}
@@ -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;
}
}
}
@@ -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<Boolean> 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<Boolean> 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<Boolean> 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<String> 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<Boolean> 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();
}
}
@@ -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);
}
}
@@ -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<String> reply);
void confirm(String message, Consumer<Boolean> reply);
/** Asks Termux to start sshd; {@code reply} receives whether the request was sent. */
void startSshd(Consumer<Boolean> 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> T askOnMain(Consumer<Consumer<T>> ask) throws InterruptedException {
CountDownLatch done = new CountDownLatch(1);
AtomicReference<T> 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<String> 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");
}
}
@@ -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);
}
};
}
}
@@ -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;
}
}
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<!-- ">_" prompt -->
<path
android:strokeColor="#4EC9B0"
android:strokeWidth="6"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:pathData="M36,40 L50,54 L36,68" />
<path
android:strokeColor="#D4D4D4"
android:strokeWidth="6"
android:strokeLineCap="round"
android:pathData="M56,68 L74,68" />
</vector>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@android:color/black" />
<foreground android:drawable="@drawable/ic_launcher_fg" />
</adaptive-icon>
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">WHC Shell</string>
</resources>
+9
View File
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.WhcShell" parent="android:Theme.Material.NoActionBar">
<item name="android:windowBackground">@android:color/black</item>
<item name="android:statusBarColor">@android:color/black</item>
<item name="android:navigationBarColor">@android:color/black</item>
<item name="android:windowLightStatusBar">false</item>
</style>
</resources>
@@ -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[<u");
assertEquals(1, emu.getKittyKeyboardFlags());
feed("\033[<5u");
assertEquals(0, emu.getKittyKeyboardFlags());
feed("\033[=1;1u");
assertEquals(1, emu.getKittyKeyboardFlags());
feed("\033[=1;3u");
assertEquals(0, emu.getKittyKeyboardFlags());
feed("\033[=1;2u");
assertEquals(1, emu.getKittyKeyboardFlags());
assertEquals("no text is printed", ' ', emu.getScreen().allocateFullLineIfNecessary(0).mText[0]);
}
@Test
public void kittyAltScreenHasOwnStack() {
feed("\033[>1u");
feed("\033[?1049h");
assertEquals(0, emu.getKittyKeyboardFlags());
feed("\033[>1u");
assertEquals(1, emu.getKittyKeyboardFlags());
feed("\033[<u");
feed("\033[?1049l");
assertEquals(1, emu.getKittyKeyboardFlags());
feed("\033c");
assertEquals(0, emu.getKittyKeyboardFlags());
}
@Test
public void modifyOtherKeys() {
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());
}
}
@@ -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));
}
}
@@ -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);
}
}
}
}