Files
termux-app/terminal-view/src/main/java/com/termux/view/TerminalView.java
agnostic-apollo 89a1e02713 Updates to terminal cursor blinking
Fixed bug where cursor would become invisible when long holding (arrow) keys when editing commands (outside of text editors like nano).

Updated javadocs with info on how cursor blinking works

"Performance Improvements" and removed redundant mRendering check
2021-05-16 23:33:44 +05:00

1122 lines
45 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.os.Handler;
import android.os.Looper;
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;
private Handler mTerminalCursorBlinkerHandler;
private TerminalCursorBlinkerRunnable mTerminalCursorBlinkerRunnable;
private int mTerminalCursorBlinkerRate;
private boolean mCursorInvisibleIgnoreOnce;
public static final int TERMINAL_CURSOR_BLINK_RATE_MIN = 100;
public static final int TERMINAL_CURSOR_BLINK_RATE_MAX = 2000;
/** 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 whether 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) {
// Ensure cursor is shown when a key is pressed down like long hold on (arrow) keys
if (mEmulator != null)
mEmulator.setCursorBlinkState(true);
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("");
}
/**
* Set terminal cursor blinker rate. It must be between {@link #TERMINAL_CURSOR_BLINK_RATE_MIN}
* and {@link #TERMINAL_CURSOR_BLINK_RATE_MAX}, otherwise it will be disabled.
*
* The {@link #setTerminalCursorBlinkerState(boolean, boolean)} must be called after this
* for changes to take effect if not disabling.
*
* @param blinkRate The value to set.
* @return Returns {@code true} if setting blinker rate was successfully set, otherwise [@code false}.
*/
public synchronized boolean setTerminalCursorBlinkerRate(int blinkRate) {
boolean result;
// If cursor blinking rate is not valid
if (blinkRate != 0 && (blinkRate < TERMINAL_CURSOR_BLINK_RATE_MIN || blinkRate > TERMINAL_CURSOR_BLINK_RATE_MAX)) {
mClient.logError(LOG_TAG, "The cursor blink rate must be in between " + TERMINAL_CURSOR_BLINK_RATE_MIN + "-" + TERMINAL_CURSOR_BLINK_RATE_MAX + ": " + blinkRate);
mTerminalCursorBlinkerRate = 0;
result = false;
} else {
mClient.logVerbose(LOG_TAG, "Setting cursor blinker rate to " + blinkRate);
mTerminalCursorBlinkerRate = blinkRate;
result = true;
}
if (mTerminalCursorBlinkerRate == 0) {
mClient.logVerbose(LOG_TAG, "Cursor blinker disabled");
stopTerminalCursorBlinker();
}
return result;
}
/**
* Sets whether cursor blinker should be started or stopped. Cursor blinker will only be
* started if {@link #mTerminalCursorBlinkerRate} does not equal 0 and is between
* {@link #TERMINAL_CURSOR_BLINK_RATE_MIN} and {@link #TERMINAL_CURSOR_BLINK_RATE_MAX}.
*
* This should be called when the view holding this activity is resumed or stopped so that
* cursor blinker does not run when activity is not visible.
*
* It should also be called on the
* {@link com.termux.terminal.TerminalSessionClient#onTerminalCursorStateChange(boolean)}
* callback when cursor is enabled or disabled so that blinker is disabled if cursor is not
* to be shown. It should also be checked if activity is visible if blinker is to be started
* before calling this.
*
* How cursor blinker starting works is by registering a {@link Runnable} with the looper of
* the main thread of the app which when run, toggles the cursor blinking state and re-registers
* itself to be called with the delay set by {@link #mTerminalCursorBlinkerRate}. When cursor
* blinking needs to be disabled, we just cancel any callbacks registered. We don't run our own
* "thread" and let the thread for the main looper do the work for us, whose usage is also
* required to update the UI, since it also handles other calls to update the UI as well based
* on a queue.
*
* Note that when moving cursor in text editors like nano, the cursor state is quickly
* toggled `-> off -> on`, which would call this very quickly sequentially. So that if cursor
* is moved 2 or more times quickly, like long hold on arrow keys, it would trigger
* `-> off -> on -> off -> on -> ...`, and the "on" callback at index 2 is automatically
* cancelled by next "off" callback at index 3 before getting a chance to be run. For this case
* we log only if {@link #TERMINAL_VIEW_KEY_LOGGING_ENABLED} is enabled, otherwise would clutter
* the log. We don't start the blinking with a delay to immediately show cursor in case it was
* previously not visible.
*
* @param start If cursor blinker should be started or stopped.
* @param startOnlyIfCursorEnabled If set to {@code true}, then it will also be checked if the
* cursor is even enabled by {@link TerminalEmulator} before
* starting the cursor blinker.
*/
public synchronized void setTerminalCursorBlinkerState(boolean start, boolean startOnlyIfCursorEnabled) {
// Stop any existing cursor blinker callbacks
stopTerminalCursorBlinker();
if (mEmulator == null) return;
mEmulator.setCursorBlinkingEnabled(false);
if (start) {
// If cursor blinker is not enabled or is not valid
if (mTerminalCursorBlinkerRate < TERMINAL_CURSOR_BLINK_RATE_MIN || mTerminalCursorBlinkerRate > TERMINAL_CURSOR_BLINK_RATE_MAX)
return;
// If cursor blinder is to be started only if cursor is enabled
else if (startOnlyIfCursorEnabled && ! mEmulator.isCursorEnabled()) {
if (TERMINAL_VIEW_KEY_LOGGING_ENABLED)
mClient.logVerbose(LOG_TAG, "Ignoring call to start cursor blinker since cursor is not enabled");
return;
}
// Start cursor blinker runnable
if (TERMINAL_VIEW_KEY_LOGGING_ENABLED)
mClient.logVerbose(LOG_TAG, "Starting cursor blinker with the blink rate " + mTerminalCursorBlinkerRate);
if (mTerminalCursorBlinkerHandler == null)
mTerminalCursorBlinkerHandler = new Handler(Looper.getMainLooper());
mTerminalCursorBlinkerRunnable = new TerminalCursorBlinkerRunnable(mEmulator, mTerminalCursorBlinkerRate);
mEmulator.setCursorBlinkingEnabled(true);
mTerminalCursorBlinkerRunnable.run();
}
}
/**
* Cancel the terminal cursor blinker callbacks
*/
private void stopTerminalCursorBlinker() {
if (mTerminalCursorBlinkerHandler != null && mTerminalCursorBlinkerRunnable != null) {
if (TERMINAL_VIEW_KEY_LOGGING_ENABLED)
mClient.logVerbose(LOG_TAG, "Stopping cursor blinker");
mTerminalCursorBlinkerHandler.removeCallbacks(mTerminalCursorBlinkerRunnable);
}
}
private class TerminalCursorBlinkerRunnable implements Runnable {
private final TerminalEmulator mEmulator;
private final int mBlinkRate;
// Initialize with false so that initial blink state is visible after toggling
boolean mCursorVisible = false;
public TerminalCursorBlinkerRunnable(TerminalEmulator emulator, int blinkRate) {
mEmulator = emulator;
mBlinkRate = blinkRate;
}
public void run() {
try {
if (mEmulator != null) {
// Toggle the blink state and then invalidate() the view so
// that onDraw() is called, which then calls TerminalRenderer.render()
// which checks with TerminalEmulator.shouldCursorBeVisible() to decide whether
// to draw the cursor or not
mCursorVisible = !mCursorVisible;
//mClient.logVerbose(LOG_TAG, "Toggling cursor blink state to " + mCursorVisible);
mEmulator.setCursorBlinkState(mCursorVisible);
invalidate();
}
} finally {
// Recall the Runnable after mBlinkRate milliseconds to toggle the blink state
mTerminalCursorBlinkerHandler.postDelayed(this, mBlinkRate);
}
}
}
/**
* 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();
}
}
}
}