termux-app/terminal-view/src/main/java/com/termux/view/TerminalView.java

964 lines
37 KiB
Java

package com.termux.view;
import android.annotation.SuppressLint;
import android.annotation.TargetApi;
import android.content.ClipData;
import android.content.ClipboardManager;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Typeface;
import android.os.Build;
import android.text.Editable;
import android.text.InputType;
import android.text.TextUtils;
import android.util.AttributeSet;
import android.view.ActionMode;
import android.view.HapticFeedbackConstants;
import android.view.InputDevice;
import android.view.KeyCharacterMap;
import android.view.KeyEvent;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewConfiguration;
import android.view.ViewTreeObserver;
import android.view.accessibility.AccessibilityManager;
import android.view.autofill.AutofillValue;
import android.view.inputmethod.BaseInputConnection;
import android.view.inputmethod.EditorInfo;
import android.view.inputmethod.InputConnection;
import android.widget.Scroller;
import androidx.annotation.RequiresApi;
import com.termux.terminal.KeyHandler;
import com.termux.terminal.TerminalEmulator;
import com.termux.terminal.TerminalSession;
import com.termux.view.textselection.TextSelectionCursorController;
/** View displaying and interacting with a {@link TerminalSession}. */
public final class TerminalView extends View {
/** Log terminal view key and IME events. */
private static boolean TERMINAL_VIEW_KEY_LOGGING_ENABLED = false;
/** The currently displayed terminal session, whose emulator is {@link #mEmulator}. */
public TerminalSession mTermSession;
/** Our terminal emulator whose session is {@link #mTermSession}. */
public TerminalEmulator mEmulator;
public TerminalRenderer mRenderer;
public TerminalViewClient mClient;
private TextSelectionCursorController mTextSelectionCursorController;
/** The top row of text to display. Ranges from -activeTranscriptRows to 0. */
int mTopRow;
int[] mDefaultSelectors = new int[]{-1,-1,-1,-1};
float mScaleFactor = 1.f;
final GestureAndScaleRecognizer mGestureRecognizer;
/** Keep track of where mouse touch event started which we report as mouse scroll. */
private int mMouseScrollStartX = -1, mMouseScrollStartY = -1;
/** Keep track of the time when a touch event leading to sending mouse scroll events started. */
private long mMouseStartDownTime = -1;
final Scroller mScroller;
/** What was left in from scrolling movement. */
float mScrollRemainder;
/** If non-zero, this is the last unicode code point received if that was a combining character. */
int mCombiningAccent;
private final boolean mAccessibilityEnabled;
private static final String LOG_TAG = "TerminalView";
public TerminalView(Context context, AttributeSet attributes) { // NO_UCD (unused code)
super(context, attributes);
mGestureRecognizer = new GestureAndScaleRecognizer(context, new GestureAndScaleRecognizer.Listener() {
boolean scrolledWithFinger;
@Override
public boolean onUp(MotionEvent event) {
mScrollRemainder = 0.0f;
if (mEmulator != null && mEmulator.isMouseTrackingActive() && !isSelectingText() && !scrolledWithFinger) {
// Quick event processing when mouse tracking is active - do not wait for check of double tapping
// for zooming.
sendMouseEventCode(event, TerminalEmulator.MOUSE_LEFT_BUTTON, true);
sendMouseEventCode(event, TerminalEmulator.MOUSE_LEFT_BUTTON, false);
return true;
}
scrolledWithFinger = false;
return false;
}
@Override
public boolean onSingleTapUp(MotionEvent event) {
if (mEmulator == null) return true;
if (isSelectingText()) {
stopTextSelectionMode();
return true;
}
requestFocus();
if (!mEmulator.isMouseTrackingActive()) {
if (!event.isFromSource(InputDevice.SOURCE_MOUSE)) {
mClient.onSingleTapUp(event);
return true;
}
}
return false;
}
@Override
public boolean onScroll(MotionEvent e, float distanceX, float distanceY) {
if (mEmulator == null) return true;
if (mEmulator.isMouseTrackingActive() && e.isFromSource(InputDevice.SOURCE_MOUSE)) {
// If moving with mouse pointer while pressing button, report that instead of scroll.
// This means that we never report moving with button press-events for touch input,
// since we cannot just start sending these events without a starting press event,
// which we do not do for touch input, only mouse in onTouchEvent().
sendMouseEventCode(e, TerminalEmulator.MOUSE_LEFT_BUTTON_MOVED, true);
} else {
scrolledWithFinger = true;
distanceY += mScrollRemainder;
int deltaRows = (int) (distanceY / mRenderer.mFontLineSpacing);
mScrollRemainder = distanceY - deltaRows * mRenderer.mFontLineSpacing;
doScroll(e, deltaRows);
}
return true;
}
@Override
public boolean onScale(float focusX, float focusY, float scale) {
if (mEmulator == null || isSelectingText()) return true;
mScaleFactor *= scale;
mScaleFactor = mClient.onScale(mScaleFactor);
return true;
}
@Override
public boolean onFling(final MotionEvent e2, float velocityX, float velocityY) {
if (mEmulator == null) return true;
// Do not start scrolling until last fling has been taken care of:
if (!mScroller.isFinished()) return true;
final boolean mouseTrackingAtStartOfFling = mEmulator.isMouseTrackingActive();
float SCALE = 0.25f;
if (mouseTrackingAtStartOfFling) {
mScroller.fling(0, 0, 0, -(int) (velocityY * SCALE), 0, 0, -mEmulator.mRows / 2, mEmulator.mRows / 2);
} else {
mScroller.fling(0, mTopRow, 0, -(int) (velocityY * SCALE), 0, 0, -mEmulator.getScreen().getActiveTranscriptRows(), 0);
}
post(new Runnable() {
private int mLastY = 0;
@Override
public void run() {
if (mouseTrackingAtStartOfFling != mEmulator.isMouseTrackingActive()) {
mScroller.abortAnimation();
return;
}
if (mScroller.isFinished()) return;
boolean more = mScroller.computeScrollOffset();
int newY = mScroller.getCurrY();
int diff = mouseTrackingAtStartOfFling ? (newY - mLastY) : (newY - mTopRow);
doScroll(e2, diff);
mLastY = newY;
if (more) post(this);
}
});
return true;
}
@Override
public boolean onDown(float x, float y) {
// Why is true not returned here?
// https://developer.android.com/training/gestures/detector.html#detect-a-subset-of-supported-gestures
// Although setting this to true still does not solve the following errors when long pressing in terminal view text area
// ViewDragHelper: Ignoring pointerId=0 because ACTION_DOWN was not received for this pointer before ACTION_MOVE
// Commenting out the call to mGestureDetector.onTouchEvent(event) in GestureAndScaleRecognizer#onTouchEvent() removes
// the error logging, so issue is related to GestureDetector
return false;
}
@Override
public boolean onDoubleTap(MotionEvent event) {
// Do not treat is as a single confirmed tap - it may be followed by zoom.
return false;
}
@Override
public void onLongPress(MotionEvent event) {
if (mGestureRecognizer.isInProgress()) return;
if (mClient.onLongPress(event)) return;
if (!isSelectingText()) {
performHapticFeedback(HapticFeedbackConstants.LONG_PRESS);
startTextSelectionMode(event);
}
}
});
mScroller = new Scroller(context);
AccessibilityManager am = (AccessibilityManager) context.getSystemService(Context.ACCESSIBILITY_SERVICE);
mAccessibilityEnabled = am.isEnabled();
}
/**
* @param client The {@link TerminalViewClient} interface implementation to allow
* for communication between {@link TerminalView} and its client.
*/
public void setTerminalViewClient(TerminalViewClient client) {
this.mClient = client;
}
/**
* Sets terminal view key logging is enabled or not.
*
* @param value The boolean value that defines the state.
*/
public void setIsTerminalViewKeyLoggingEnabled(boolean value) {
TERMINAL_VIEW_KEY_LOGGING_ENABLED = value;
}
/**
* Attach a {@link TerminalSession} to this view.
*
* @param session The {@link TerminalSession} this view will be displaying.
*/
public boolean attachSession(TerminalSession session) {
if (session == mTermSession) return false;
mTopRow = 0;
mTermSession = session;
mEmulator = null;
mCombiningAccent = 0;
updateSize();
// Wait with enabling the scrollbar until we have a terminal to get scroll position from.
setVerticalScrollBarEnabled(true);
return true;
}
@Override
public InputConnection onCreateInputConnection(EditorInfo outAttrs) {
if (mClient.shouldEnforceCharBasedInput()) {
// Some keyboards seems do not reset the internal state on TYPE_NULL.
// Affects mostly Samsung stock keyboards.
// https://github.com/termux/termux-app/issues/686
outAttrs.inputType = InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD | InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS;
} else {
// Using InputType.NULL is the most correct input type and avoids issues with other hacks.
//
// Previous keyboard issues:
// https://github.com/termux/termux-packages/issues/25
// https://github.com/termux/termux-app/issues/87.
// https://github.com/termux/termux-app/issues/126.
// https://github.com/termux/termux-app/issues/137 (japanese chars and TYPE_NULL).
outAttrs.inputType = InputType.TYPE_NULL;
}
// Note that IME_ACTION_NONE cannot be used as that makes it impossible to input newlines using the on-screen
// keyboard on Android TV (see https://github.com/termux/termux-app/issues/221).
outAttrs.imeOptions = EditorInfo.IME_FLAG_NO_FULLSCREEN;
return new BaseInputConnection(this, true) {
@Override
public boolean finishComposingText() {
if (TERMINAL_VIEW_KEY_LOGGING_ENABLED) mClient.logInfo(LOG_TAG, "IME: finishComposingText()");
super.finishComposingText();
sendTextToTerminal(getEditable());
getEditable().clear();
return true;
}
@Override
public boolean commitText(CharSequence text, int newCursorPosition) {
if (TERMINAL_VIEW_KEY_LOGGING_ENABLED) {
mClient.logInfo(LOG_TAG, "IME: commitText(\"" + text + "\", " + newCursorPosition + ")");
}
super.commitText(text, newCursorPosition);
if (mEmulator == null) return true;
Editable content = getEditable();
sendTextToTerminal(content);
content.clear();
return true;
}
@Override
public boolean deleteSurroundingText(int leftLength, int rightLength) {
if (TERMINAL_VIEW_KEY_LOGGING_ENABLED) {
mClient.logInfo(LOG_TAG, "IME: deleteSurroundingText(" + leftLength + ", " + rightLength + ")");
}
// The stock Samsung keyboard with 'Auto check spelling' enabled sends leftLength > 1.
KeyEvent deleteKey = new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DEL);
for (int i = 0; i < leftLength; i++) sendKeyEvent(deleteKey);
return super.deleteSurroundingText(leftLength, rightLength);
}
void sendTextToTerminal(CharSequence text) {
stopTextSelectionMode();
final int textLengthInChars = text.length();
for (int i = 0; i < textLengthInChars; i++) {
char firstChar = text.charAt(i);
int codePoint;
if (Character.isHighSurrogate(firstChar)) {
if (++i < textLengthInChars) {
codePoint = Character.toCodePoint(firstChar, text.charAt(i));
} else {
// At end of string, with no low surrogate following the high:
codePoint = TerminalEmulator.UNICODE_REPLACEMENT_CHAR;
}
} else {
codePoint = firstChar;
}
boolean ctrlHeld = false;
if (codePoint <= 31 && codePoint != 27) {
if (codePoint == '\n') {
// The AOSP keyboard and descendants seems to send \n as text when the enter key is pressed,
// instead of a key event like most other keyboard apps. A terminal expects \r for the enter
// key (although when icrnl is enabled this doesn't make a difference - run 'stty -icrnl' to
// check the behaviour).
codePoint = '\r';
}
// E.g. penti keyboard for ctrl input.
ctrlHeld = true;
switch (codePoint) {
case 31:
codePoint = '_';
break;
case 30:
codePoint = '^';
break;
case 29:
codePoint = ']';
break;
case 28:
codePoint = '\\';
break;
default:
codePoint += 96;
break;
}
}
inputCodePoint(codePoint, ctrlHeld, false);
}
}
};
}
@Override
protected int computeVerticalScrollRange() {
return mEmulator == null ? 1 : mEmulator.getScreen().getActiveRows();
}
@Override
protected int computeVerticalScrollExtent() {
return mEmulator == null ? 1 : mEmulator.mRows;
}
@Override
protected int computeVerticalScrollOffset() {
return mEmulator == null ? 1 : mEmulator.getScreen().getActiveRows() + mTopRow - mEmulator.mRows;
}
public void onScreenUpdated() {
if (mEmulator == null) return;
int rowsInHistory = mEmulator.getScreen().getActiveTranscriptRows();
if (mTopRow < -rowsInHistory) mTopRow = -rowsInHistory;
boolean skipScrolling = false;
if (isSelectingText()) {
// Do not scroll when selecting text.
int rowShift = mEmulator.getScrollCounter();
if (-mTopRow + rowShift > rowsInHistory) {
// .. unless we're hitting the end of history transcript, in which
// case we abort text selection and scroll to end.
stopTextSelectionMode();
} else {
skipScrolling = true;
mTopRow -= rowShift;
decrementYTextSelectionCursors(rowShift);
}
}
if (!skipScrolling && mTopRow != 0) {
// Scroll down if not already there.
if (mTopRow < -3) {
// Awaken scroll bars only if scrolling a noticeable amount
// - we do not want visible scroll bars during normal typing
// of one row at a time.
awakenScrollBars();
}
mTopRow = 0;
}
mEmulator.clearScrollCounter();
invalidate();
if (mAccessibilityEnabled) setContentDescription(getText());
}
/**
* Sets the text size, which in turn sets the number of rows and columns.
*
* @param textSize the new font size, in density-independent pixels.
*/
public void setTextSize(int textSize) {
mRenderer = new TerminalRenderer(textSize, mRenderer == null ? Typeface.MONOSPACE : mRenderer.mTypeface);
updateSize();
}
public void setTypeface(Typeface newTypeface) {
mRenderer = new TerminalRenderer(mRenderer.mTextSize, newTypeface);
updateSize();
invalidate();
}
@Override
public boolean onCheckIsTextEditor() {
return true;
}
@Override
public boolean isOpaque() {
return true;
}
/** Send a single mouse event code to the terminal. */
void sendMouseEventCode(MotionEvent e, int button, boolean pressed) {
int x = (int) (e.getX() / mRenderer.mFontWidth) + 1;
int y = (int) ((e.getY() - mRenderer.mFontLineSpacingAndAscent) / mRenderer.mFontLineSpacing) + 1;
if (pressed && (button == TerminalEmulator.MOUSE_WHEELDOWN_BUTTON || button == TerminalEmulator.MOUSE_WHEELUP_BUTTON)) {
if (mMouseStartDownTime == e.getDownTime()) {
x = mMouseScrollStartX;
y = mMouseScrollStartY;
} else {
mMouseStartDownTime = e.getDownTime();
mMouseScrollStartX = x;
mMouseScrollStartY = y;
}
}
mEmulator.sendMouseEvent(button, x, y, pressed);
}
/** Perform a scroll, either from dragging the screen or by scrolling a mouse wheel. */
void doScroll(MotionEvent event, int rowsDown) {
boolean up = rowsDown < 0;
int amount = Math.abs(rowsDown);
for (int i = 0; i < amount; i++) {
if (mEmulator.isMouseTrackingActive()) {
sendMouseEventCode(event, up ? TerminalEmulator.MOUSE_WHEELUP_BUTTON : TerminalEmulator.MOUSE_WHEELDOWN_BUTTON, true);
} else if (mEmulator.isAlternateBufferActive()) {
// Send up and down key events for scrolling, which is what some terminals do to make scroll work in
// e.g. less, which shifts to the alt screen without mouse handling.
handleKeyCode(up ? KeyEvent.KEYCODE_DPAD_UP : KeyEvent.KEYCODE_DPAD_DOWN, 0);
} else {
mTopRow = Math.min(0, Math.max(-(mEmulator.getScreen().getActiveTranscriptRows()), mTopRow + (up ? -1 : 1)));
if (!awakenScrollBars()) invalidate();
}
}
}
/** Overriding {@link View#onGenericMotionEvent(MotionEvent)}. */
@Override
public boolean onGenericMotionEvent(MotionEvent event) {
if (mEmulator != null && event.isFromSource(InputDevice.SOURCE_MOUSE) && event.getAction() == MotionEvent.ACTION_SCROLL) {
// Handle mouse wheel scrolling.
boolean up = event.getAxisValue(MotionEvent.AXIS_VSCROLL) > 0.0f;
doScroll(event, up ? -3 : 3);
return true;
}
return false;
}
@SuppressLint("ClickableViewAccessibility")
@Override
@TargetApi(23)
public boolean onTouchEvent(MotionEvent event) {
if (mEmulator == null) return true;
final int action = event.getAction();
if (isSelectingText()) {
updateFloatingToolbarVisibility(event);
mGestureRecognizer.onTouchEvent(event);
return true;
} else if (event.isFromSource(InputDevice.SOURCE_MOUSE)) {
if (event.isButtonPressed(MotionEvent.BUTTON_SECONDARY)) {
if (action == MotionEvent.ACTION_DOWN) showContextMenu();
return true;
} else if (event.isButtonPressed(MotionEvent.BUTTON_TERTIARY)) {
ClipboardManager clipboard = (ClipboardManager) getContext().getSystemService(Context.CLIPBOARD_SERVICE);
ClipData clipData = clipboard.getPrimaryClip();
if (clipData != null) {
CharSequence paste = clipData.getItemAt(0).coerceToText(getContext());
if (!TextUtils.isEmpty(paste)) mEmulator.paste(paste.toString());
}
} else if (mEmulator.isMouseTrackingActive()) { // BUTTON_PRIMARY.
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
case MotionEvent.ACTION_UP:
sendMouseEventCode(event, TerminalEmulator.MOUSE_LEFT_BUTTON, event.getAction() == MotionEvent.ACTION_DOWN);
break;
case MotionEvent.ACTION_MOVE:
sendMouseEventCode(event, TerminalEmulator.MOUSE_LEFT_BUTTON_MOVED, true);
break;
}
return true;
}
}
mGestureRecognizer.onTouchEvent(event);
return true;
}
@Override
public boolean onKeyPreIme(int keyCode, KeyEvent event) {
if (TERMINAL_VIEW_KEY_LOGGING_ENABLED)
mClient.logInfo(LOG_TAG, "onKeyPreIme(keyCode=" + keyCode + ", event=" + event + ")");
if (keyCode == KeyEvent.KEYCODE_BACK) {
if (isSelectingText()) {
stopTextSelectionMode();
return true;
} else if (mClient.shouldBackButtonBeMappedToEscape()) {
// Intercept back button to treat it as escape:
switch (event.getAction()) {
case KeyEvent.ACTION_DOWN:
return onKeyDown(keyCode, event);
case KeyEvent.ACTION_UP:
return onKeyUp(keyCode, event);
}
}
} else if (mClient.shouldUseCtrlSpaceWorkaround() &&
keyCode == KeyEvent.KEYCODE_SPACE && event.isCtrlPressed()) {
/* ctrl+space does not work on some ROMs without this workaround.
However, this breaks it on devices where it works out of the box. */
return onKeyDown(keyCode, event);
}
return super.onKeyPreIme(keyCode, event);
}
@Override
public boolean onKeyDown(int keyCode, KeyEvent event) {
if (TERMINAL_VIEW_KEY_LOGGING_ENABLED)
mClient.logInfo(LOG_TAG, "onKeyDown(keyCode=" + keyCode + ", isSystem()=" + event.isSystem() + ", event=" + event + ")");
if (mEmulator == null) return true;
if (isSelectingText()) {
stopTextSelectionMode();
}
if (mClient.onKeyDown(keyCode, event, mTermSession)) {
invalidate();
return true;
} else if (event.isSystem() && (!mClient.shouldBackButtonBeMappedToEscape() || keyCode != KeyEvent.KEYCODE_BACK)) {
return super.onKeyDown(keyCode, event);
} else if (event.getAction() == KeyEvent.ACTION_MULTIPLE && keyCode == KeyEvent.KEYCODE_UNKNOWN) {
mTermSession.write(event.getCharacters());
return true;
}
final int metaState = event.getMetaState();
final boolean controlDown = event.isCtrlPressed() || mClient.readControlKey();
final boolean leftAltDown = (metaState & KeyEvent.META_ALT_LEFT_ON) != 0 || mClient.readAltKey();
final boolean rightAltDownFromEvent = (metaState & KeyEvent.META_ALT_RIGHT_ON) != 0;
int keyMod = 0;
if (controlDown) keyMod |= KeyHandler.KEYMOD_CTRL;
if (event.isAltPressed() || leftAltDown) keyMod |= KeyHandler.KEYMOD_ALT;
if (event.isShiftPressed()) keyMod |= KeyHandler.KEYMOD_SHIFT;
if (event.isNumLockOn()) keyMod |= KeyHandler.KEYMOD_NUM_LOCK;
if (!event.isFunctionPressed() && handleKeyCode(keyCode, keyMod)) {
if (TERMINAL_VIEW_KEY_LOGGING_ENABLED) mClient.logInfo(LOG_TAG, "handleKeyCode() took key event");
return true;
}
// Clear Ctrl since we handle that ourselves:
int bitsToClear = KeyEvent.META_CTRL_MASK;
if (rightAltDownFromEvent) {
// Let right Alt/Alt Gr be used to compose characters.
} else {
// Use left alt to send to terminal (e.g. Left Alt+B to jump back a word), so remove:
bitsToClear |= KeyEvent.META_ALT_ON | KeyEvent.META_ALT_LEFT_ON;
}
int effectiveMetaState = event.getMetaState() & ~bitsToClear;
int result = event.getUnicodeChar(effectiveMetaState);
if (TERMINAL_VIEW_KEY_LOGGING_ENABLED)
mClient.logInfo(LOG_TAG, "KeyEvent#getUnicodeChar(" + effectiveMetaState + ") returned: " + result);
if (result == 0) {
return false;
}
int oldCombiningAccent = mCombiningAccent;
if ((result & KeyCharacterMap.COMBINING_ACCENT) != 0) {
// If entered combining accent previously, write it out:
if (mCombiningAccent != 0)
inputCodePoint(mCombiningAccent, controlDown, leftAltDown);
mCombiningAccent = result & KeyCharacterMap.COMBINING_ACCENT_MASK;
} else {
if (mCombiningAccent != 0) {
int combinedChar = KeyCharacterMap.getDeadChar(mCombiningAccent, result);
if (combinedChar > 0) result = combinedChar;
mCombiningAccent = 0;
}
inputCodePoint(result, controlDown, leftAltDown);
}
if (mCombiningAccent != oldCombiningAccent) invalidate();
return true;
}
public void inputCodePoint(int codePoint, boolean controlDownFromEvent, boolean leftAltDownFromEvent) {
if (TERMINAL_VIEW_KEY_LOGGING_ENABLED) {
mClient.logInfo(LOG_TAG, "inputCodePoint(codePoint=" + codePoint + ", controlDownFromEvent=" + controlDownFromEvent + ", leftAltDownFromEvent="
+ leftAltDownFromEvent + ")");
}
if (mTermSession == null) return;
final boolean controlDown = controlDownFromEvent || mClient.readControlKey();
final boolean altDown = leftAltDownFromEvent || mClient.readAltKey();
if (mClient.onCodePoint(codePoint, controlDown, mTermSession)) return;
if (controlDown) {
if (codePoint >= 'a' && codePoint <= 'z') {
codePoint = codePoint - 'a' + 1;
} else if (codePoint >= 'A' && codePoint <= 'Z') {
codePoint = codePoint - 'A' + 1;
} else if (codePoint == ' ' || codePoint == '2') {
codePoint = 0;
} else if (codePoint == '[' || codePoint == '3') {
codePoint = 27; // ^[ (Esc)
} else if (codePoint == '\\' || codePoint == '4') {
codePoint = 28;
} else if (codePoint == ']' || codePoint == '5') {
codePoint = 29;
} else if (codePoint == '^' || codePoint == '6') {
codePoint = 30; // control-^
} else if (codePoint == '_' || codePoint == '7' || codePoint == '/') {
// "Ctrl-/ sends 0x1f which is equivalent of Ctrl-_ since the days of VT102"
// - http://apple.stackexchange.com/questions/24261/how-do-i-send-c-that-is-control-slash-to-the-terminal
codePoint = 31;
} else if (codePoint == '8') {
codePoint = 127; // DEL
}
}
if (codePoint > -1) {
// Work around bluetooth keyboards sending funny unicode characters instead
// of the more normal ones from ASCII that terminal programs expect - the
// desire to input the original characters should be low.
switch (codePoint) {
case 0x02DC: // SMALL TILDE.
codePoint = 0x007E; // TILDE (~).
break;
case 0x02CB: // MODIFIER LETTER GRAVE ACCENT.
codePoint = 0x0060; // GRAVE ACCENT (`).
break;
case 0x02C6: // MODIFIER LETTER CIRCUMFLEX ACCENT.
codePoint = 0x005E; // CIRCUMFLEX ACCENT (^).
break;
}
// If left alt, send escape before the code point to make e.g. Alt+B and Alt+F work in readline:
mTermSession.writeCodePoint(altDown, codePoint);
}
}
/** Input the specified keyCode if applicable and return if the input was consumed. */
public boolean handleKeyCode(int keyCode, int keyMod) {
TerminalEmulator term = mTermSession.getEmulator();
String code = KeyHandler.getCode(keyCode, keyMod, term.isCursorKeysApplicationMode(), term.isKeypadApplicationMode());
if (code == null) return false;
mTermSession.write(code);
return true;
}
/**
* Called when a key is released in the view.
*
* @param keyCode The keycode of the key which was released.
* @param event A {@link KeyEvent} describing the event.
* @return Whether the event was handled.
*/
@Override
public boolean onKeyUp(int keyCode, KeyEvent event) {
if (TERMINAL_VIEW_KEY_LOGGING_ENABLED)
mClient.logInfo(LOG_TAG, "onKeyUp(keyCode=" + keyCode + ", event=" + event + ")");
if (mEmulator == null) return true;
if (mClient.onKeyUp(keyCode, event)) {
invalidate();
return true;
} else if (event.isSystem()) {
// Let system key events through.
return super.onKeyUp(keyCode, event);
}
return true;
}
/**
* This is called during layout when the size of this view has changed. If you were just added to the view
* hierarchy, you're called with the old values of 0.
*/
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
updateSize();
}
/** Check if the terminal size in rows and columns should be updated. */
public void updateSize() {
int viewWidth = getWidth();
int viewHeight = getHeight();
if (viewWidth == 0 || viewHeight == 0 || mTermSession == null) return;
// Set to 80 and 24 if you want to enable vttest.
int newColumns = Math.max(4, (int) (viewWidth / mRenderer.mFontWidth));
int newRows = Math.max(4, (viewHeight - mRenderer.mFontLineSpacingAndAscent) / mRenderer.mFontLineSpacing);
if (mEmulator == null || (newColumns != mEmulator.mColumns || newRows != mEmulator.mRows)) {
mTermSession.updateSize(newColumns, newRows);
mEmulator = mTermSession.getEmulator();
mTopRow = 0;
scrollTo(0, 0);
invalidate();
}
}
@Override
protected void onDraw(Canvas canvas) {
if (mEmulator == null) {
canvas.drawColor(0XFF000000);
} else {
// render the terminal view and highlight any selected text
int[] sel = mDefaultSelectors;
if (mTextSelectionCursorController != null) {
mTextSelectionCursorController.getSelectors(sel);
}
mRenderer.render(mEmulator, canvas, mTopRow, sel[0], sel[1], sel[2], sel[3]);
// render the text selection handles
renderTextSelection();
}
}
public TerminalSession getCurrentSession() {
return mTermSession;
}
private CharSequence getText() {
return mEmulator.getScreen().getSelectedText(0, mTopRow, mEmulator.mColumns, mTopRow + mEmulator.mRows);
}
public int getCursorX(float x) {
return (int) (x / mRenderer.mFontWidth);
}
public int getCursorY(float y) {
return (int) (((y - 40) / mRenderer.mFontLineSpacing) + mTopRow);
}
public int getPointX(int cx) {
if (cx > mEmulator.mColumns) {
cx = mEmulator.mColumns;
}
return Math.round(cx * mRenderer.mFontWidth);
}
public int getPointY(int cy) {
return Math.round((cy - mTopRow) * mRenderer.mFontLineSpacing);
}
public int getTopRow() {
return mTopRow;
}
public void setTopRow(int mTopRow) {
this.mTopRow = mTopRow;
}
/**
* Define functions required for AutoFill API
*/
@RequiresApi(api = Build.VERSION_CODES.O)
@Override
public void autofill(AutofillValue value) {
if (value.isText()) {
mTermSession.write(value.getTextValue().toString());
}
}
@RequiresApi(api = Build.VERSION_CODES.O)
@Override
public int getAutofillType() {
return AUTOFILL_TYPE_TEXT;
}
@RequiresApi(api = Build.VERSION_CODES.O)
@Override
public AutofillValue getAutofillValue() {
return AutofillValue.forText("");
}
/**
* Define functions required for text selection and its handles.
*/
TextSelectionCursorController getTextSelectionCursorController() {
if (mTextSelectionCursorController == null) {
mTextSelectionCursorController = new TextSelectionCursorController(this);
final ViewTreeObserver observer = getViewTreeObserver();
if (observer != null) {
observer.addOnTouchModeChangeListener(mTextSelectionCursorController);
}
}
return mTextSelectionCursorController;
}
private void showTextSelectionCursors(MotionEvent event) {
getTextSelectionCursorController().show(event);
}
private boolean hideTextSelectionCursors() {
return getTextSelectionCursorController().hide();
}
private void renderTextSelection() {
if (mTextSelectionCursorController != null)
mTextSelectionCursorController.render();
}
public boolean isSelectingText() {
if (mTextSelectionCursorController != null) {
return mTextSelectionCursorController.isActive();
} else {
return false;
}
}
private ActionMode getTextSelectionActionMode() {
if (mTextSelectionCursorController != null) {
return mTextSelectionCursorController.getActionMode();
} else {
return null;
}
}
public void startTextSelectionMode(MotionEvent event) {
if (!requestFocus()) {
return;
}
showTextSelectionCursors(event);
mClient.copyModeChanged(isSelectingText());
invalidate();
}
public void stopTextSelectionMode() {
if (hideTextSelectionCursors()) {
mClient.copyModeChanged(isSelectingText());
invalidate();
}
}
private void decrementYTextSelectionCursors(int decrement) {
if (mTextSelectionCursorController != null) {
mTextSelectionCursorController.decrementYTextSelectionCursors(decrement);
}
}
@Override
protected void onAttachedToWindow() {
super.onAttachedToWindow();
if (mTextSelectionCursorController != null) {
getViewTreeObserver().addOnTouchModeChangeListener(mTextSelectionCursorController);
}
}
@Override
protected void onDetachedFromWindow() {
super.onDetachedFromWindow();
if (mTextSelectionCursorController != null) {
// Might solve the following exception
// android.view.WindowLeaked: Activity com.termux.app.TermuxActivity has leaked window android.widget.PopupWindow
stopTextSelectionMode();
getViewTreeObserver().removeOnTouchModeChangeListener(mTextSelectionCursorController);
mTextSelectionCursorController.onDetached();
}
}
/**
* Define functions required for long hold toolbar.
*/
private final Runnable mShowFloatingToolbar = new Runnable() {
@Override
public void run() {
if (getTextSelectionActionMode() != null) {
getTextSelectionActionMode().hide(0); // hide off.
}
}
};
private void showFloatingToolbar() {
if (getTextSelectionActionMode() != null) {
int delay = ViewConfiguration.getDoubleTapTimeout();
postDelayed(mShowFloatingToolbar, delay);
}
}
void hideFloatingToolbar() {
if (getTextSelectionActionMode() != null) {
removeCallbacks(mShowFloatingToolbar);
getTextSelectionActionMode().hide(-1);
}
}
public void updateFloatingToolbarVisibility(MotionEvent event) {
if (getTextSelectionActionMode() != null) {
switch (event.getActionMasked()) {
case MotionEvent.ACTION_MOVE:
hideFloatingToolbar();
break;
case MotionEvent.ACTION_UP: // fall through
case MotionEvent.ACTION_CANCEL:
showFloatingToolbar();
}
}
}
}