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
+9
View File
@@ -0,0 +1,9 @@
.gradle/
build/
app/build/
local.properties
*.keystore
*.jks
keystore.properties
.idea/
*.iml
+21
View File
@@ -0,0 +1,21 @@
WHC Shell
Copyright 2026 whc
This product includes software from the Termux project
(https://github.com/termux/termux-app, tag v0.118.3), licensed under the
Apache License, Version 2.0 (see third_party/Apache-2.0.txt and
third_party/termux-LICENSE.md):
app/src/main/java/com/termux/terminal/* (terminal-emulator module)
app/src/main/java/com/termux/view/TerminalRenderer.java (terminal-view module)
Modifications made for WHC Shell:
- TerminalSessionClient: removed callbacks that take a TerminalSession.
- ByteQueue: made public.
- TerminalRenderer: added getFontLineSpacingAndAscent().
The terminal emulator in Termux is itself based on Jack Palevich's
Android-Terminal-Emulator (Apache License, Version 2.0).
Runtime dependencies (Apache License 2.0 / MIT):
sshj (com.hierynomus:sshj), Bouncy Castle (bcprov, bcpkix), SLF4J.
+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);
}
}
}
}
+3
View File
@@ -0,0 +1,3 @@
plugins {
id "com.android.application" version "8.10.1" apply false
}
+3
View File
@@ -0,0 +1,3 @@
org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8
android.useAndroidX=true
android.nonTransitiveRClass=true
+18
View File
@@ -0,0 +1,18 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "whc-shell"
include ":app"
+27
View File
@@ -0,0 +1,27 @@
{ pkgs ? import <nixpkgs> {
config = {
allowUnfree = true;
android_sdk.accept_license = true;
};
}
}:
let
buildTools = "35.0.0";
android = pkgs.androidenv.composeAndroidPackages {
platformVersions = [ "35" ];
buildToolsVersions = [ buildTools ];
includeEmulator = false;
includeSystemImages = false;
includeNDK = false;
};
sdk = "${android.androidsdk}/libexec/android-sdk";
in
pkgs.mkShell {
packages = [ pkgs.jdk17 pkgs.gradle android.androidsdk pkgs.android-tools ];
ANDROID_HOME = sdk;
ANDROID_SDK_ROOT = sdk;
JAVA_HOME = pkgs.jdk17.home;
# The aapt2 that Gradle fetches from Maven is not runnable on NixOS.
GRADLE_OPTS = "-Dorg.gradle.project.android.aapt2FromMavenOverride=${sdk}/build-tools/${buildTools}/aapt2";
}
+202
View File
@@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+6
View File
@@ -0,0 +1,6 @@
The `termux/termux-app` repository is released under [GPLv3 only](https://www.gnu.org/licenses/gpl-3.0.html) license.
### Exceptions
- [Terminal Emulator for Android](https://github.com/jackpal/Android-Terminal-Emulator) code is used which is released under [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). Check [`terminal-view`](terminal-view) and [`terminal-emulator`](terminal-emulator) libraries.
- Check [`termux-shared/LICENSE.md`](termux-shared/LICENSE.md) for `termux-shared` library related exceptions.