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:
@@ -0,0 +1,9 @@
|
||||
.gradle/
|
||||
build/
|
||||
app/build/
|
||||
local.properties
|
||||
*.keystore
|
||||
*.jks
|
||||
keystore.properties
|
||||
.idea/
|
||||
*.iml
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">WHC Shell</string>
|
||||
</resources>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
plugins {
|
||||
id "com.android.application" version "8.10.1" apply false
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8
|
||||
android.useAndroidX=true
|
||||
android.nonTransitiveRClass=true
|
||||
@@ -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"
|
||||
@@ -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";
|
||||
}
|
||||
Vendored
+202
@@ -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.
|
||||
Vendored
+6
@@ -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.
|
||||
Reference in New Issue
Block a user