/* * Copyright (C) 2007 The Android Open Source Project * * 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. */ package jackpal.androidterm.emulatorview; import jackpal.androidterm.emulatorview.compat.ClipboardManagerCompat; import jackpal.androidterm.emulatorview.compat.ClipboardManagerCompatFactory; import jackpal.androidterm.emulatorview.compat.KeycodeConstants; import jackpal.androidterm.emulatorview.compat.Patterns; import java.io.IOException; import java.util.Arrays; import java.util.Hashtable; import android.content.Context; import android.graphics.Canvas; import android.graphics.Paint; import android.os.Build; import android.os.Bundle; import android.os.Handler; import android.text.SpannableStringBuilder; import android.text.TextUtils; import android.text.style.URLSpan; import android.text.util.Linkify; import android.text.util.Linkify.MatchFilter; import android.util.AttributeSet; import android.util.DisplayMetrics; import android.util.Log; import android.view.GestureDetector; import android.view.KeyEvent; import android.view.MotionEvent; import android.view.View; import android.view.inputmethod.BaseInputConnection; import android.view.inputmethod.CompletionInfo; import android.view.inputmethod.CorrectionInfo; import android.view.inputmethod.EditorInfo; import android.view.inputmethod.ExtractedText; import android.view.inputmethod.ExtractedTextRequest; import android.view.inputmethod.InputConnection; import android.widget.Scroller; /** * A view on a {@link TermSession}. Displays the terminal emulator's screen, * provides access to its scrollback buffer, and passes input through to the * terminal emulator. * <p> * If this view is inflated from an XML layout, you need to call {@link * #attachSession attachSession} and {@link #setDensity setDensity} before using * the view. If creating this view from code, use the {@link * #EmulatorView(Context, TermSession, DisplayMetrics)} constructor, which will * take care of this for you. */ public class EmulatorView extends View implements GestureDetector.OnGestureListener { private final static String TAG = "EmulatorView"; private final static boolean LOG_KEY_EVENTS = false; private final static boolean LOG_IME = false; /** * We defer some initialization until we have been layed out in the view * hierarchy. The boolean tracks when we know what our size is. */ private boolean mKnownSize; // Set if initialization was deferred because a TermSession wasn't attached private boolean mDeferInit = false; private int mVisibleWidth; private int mVisibleHeight; private TermSession mTermSession; /** * Total width of each character, in pixels */ private float mCharacterWidth; /** * Total height of each character, in pixels */ private int mCharacterHeight; /** * Top-of-screen margin */ private int mTopOfScreenMargin; /** * Used to render text */ private TextRenderer mTextRenderer; /** * Text size. Zero means 4 x 8 font. */ private int mTextSize = 10; private int mCursorBlink; /** * Color scheme (default foreground/background colors). */ private ColorScheme mColorScheme = BaseTextRenderer.defaultColorScheme; private Paint mForegroundPaint; private Paint mBackgroundPaint; private boolean mUseCookedIme; /** * Our terminal emulator. */ private TerminalEmulator mEmulator; /** * The number of rows of text to display. */ private int mRows; /** * The number of columns of text to display. */ private int mColumns; /** * The number of columns that are visible on the display. */ private int mVisibleColumns; /* * The number of rows that are visible on the view */ private int mVisibleRows; /** * The top row of text to display. Ranges from -activeTranscriptRows to 0 */ private int mTopRow; private int mLeftColumn; private static final int CURSOR_BLINK_PERIOD = 1000; private boolean mCursorVisible = true; private boolean mIsSelectingText = false; private boolean mBackKeySendsCharacter = false; private int mControlKeyCode; private int mFnKeyCode; private boolean mIsControlKeySent = false; private boolean mIsFnKeySent = false; private boolean mMouseTracking; private float mDensity; private float mScaledDensity; private static final int SELECT_TEXT_OFFSET_Y = -40; private int mSelXAnchor = -1; private int mSelYAnchor = -1; private int mSelX1 = -1; private int mSelY1 = -1; private int mSelX2 = -1; private int mSelY2 = -1; /** * Routing alt and meta keyCodes away from the IME allows Alt key processing to work on * the Asus Transformer TF101. * It doesn't seem to harm anything else, but it also doesn't seem to be * required on other platforms. * * This test should be refined as we learn more. */ private final static boolean sTrapAltAndMeta = Build.MODEL.contains("Transformer TF101"); private Runnable mBlinkCursor = new Runnable() { public void run() { if (mCursorBlink != 0) { mCursorVisible = ! mCursorVisible; mHandler.postDelayed(this, CURSOR_BLINK_PERIOD); } else { mCursorVisible = true; } // Perhaps just invalidate the character with the cursor. invalidate(); } }; private GestureDetector mGestureDetector; private GestureDetector.OnGestureListener mExtGestureListener; private Scroller mScroller; private Runnable mFlingRunner = new Runnable() { public void run() { if (mScroller.isFinished()) { return; } // Check whether mouse tracking was turned on during fling. if (isMouseTrackingActive()) { return; } boolean more = mScroller.computeScrollOffset(); int newTopRow = mScroller.getCurrY(); if (newTopRow != mTopRow) { mTopRow = newTopRow; invalidate(); } if (more) { post(this); } } }; /** * * A hash table of underlying URLs to implement clickable links. */ private Hashtable<Integer,URLSpan[]> mLinkLayer = new Hashtable<Integer,URLSpan[]>(); /** * Accept links that start with http[s]: */ private static class HttpMatchFilter implements MatchFilter { public boolean acceptMatch(CharSequence s, int start, int end) { return startsWith(s, start, end, "http:") || startsWith(s, start, end, "https:"); } private boolean startsWith(CharSequence s, int start, int end, String prefix) { int prefixLen = prefix.length(); int fragmentLen = end - start; if (prefixLen > fragmentLen) { return false; } for (int i = 0; i < prefixLen; i++) { if (s.charAt(start + i) != prefix.charAt(i)) { return false; } } return true; } } private static MatchFilter sHttpMatchFilter = new HttpMatchFilter(); /** * Convert any URLs in the current row into a URLSpan, * and store that result in a hash table of URLSpan entries. * * @param row The number of the row to check for links * @return The number of lines in a multi-line-wrap set of links */ private int createLinks(int row) { TranscriptScreen transcriptScreen = mEmulator.getScreen(); char [] line = transcriptScreen.getScriptLine(row); int lineCount = 1; //Nothing to do if there's no text. if(line == null) return lineCount; /* If this is not a basic line, the array returned from getScriptLine() * could have arbitrary garbage at the end -- find the point at which * the line ends and only include that in the text to linkify. * * XXX: The fact that the array returned from getScriptLine() on a * basic line contains no garbage is an implementation detail -- the * documented behavior explicitly allows garbage at the end! */ int lineLen; boolean textIsBasic = transcriptScreen.isBasicLine(row); if (textIsBasic) { lineLen = line.length; } else { // The end of the valid data is marked by a NUL character for (lineLen = 0; line[lineLen] != 0; ++lineLen); } SpannableStringBuilder textToLinkify = new SpannableStringBuilder(new String(line, 0, lineLen)); boolean lineWrap = transcriptScreen.getScriptLineWrap(row); //While the current line has a wrap while (lineWrap) { //Get next line int nextRow = row + lineCount; line = transcriptScreen.getScriptLine(nextRow); //If next line is blank, don't try and append if(line == null) break; boolean lineIsBasic = transcriptScreen.isBasicLine(nextRow); if (textIsBasic && !lineIsBasic) { textIsBasic = lineIsBasic; } if (lineIsBasic) { lineLen = line.length; } else { // The end of the valid data is marked by a NUL character for (lineLen = 0; line[lineLen] != 0; ++lineLen); } textToLinkify.append(new String(line, 0, lineLen)); //Check if line after next is wrapped lineWrap = transcriptScreen.getScriptLineWrap(nextRow); ++lineCount; } Linkify.addLinks(textToLinkify, Patterns.WEB_URL, null, sHttpMatchFilter, null); URLSpan [] urls = textToLinkify.getSpans(0, textToLinkify.length(), URLSpan.class); if(urls.length > 0) { int columns = mColumns; //re-index row to 0 if it is negative int screenRow = row - mTopRow; //Create and initialize set of links URLSpan [][] linkRows = new URLSpan[lineCount][]; for(int i=0; i<lineCount; ++i) { linkRows[i] = new URLSpan[columns]; Arrays.fill(linkRows[i], null); } //For each URL: for(int urlNum=0; urlNum<urls.length; ++urlNum) { URLSpan url = urls[urlNum]; int spanStart = textToLinkify.getSpanStart(url); int spanEnd = textToLinkify.getSpanEnd(url); // Build accurate indices for links int startRow; int startCol; int endRow; int endCol; if (textIsBasic) { /* endRow/endCol must be the last character of the link, * not one after -- otherwise endRow might be too large */ int spanLastPos = spanEnd - 1; // Basic line -- can assume one char per column startRow = spanStart / mColumns; startCol = spanStart % mColumns; endRow = spanLastPos / mColumns; endCol = spanLastPos % mColumns; } else { /* Iterate over the line to get starting and ending columns * for this span */ startRow = 0; startCol = 0; for (int i = 0; i < spanStart; ++i) { char c = textToLinkify.charAt(i); if (Character.isHighSurrogate(c)) { ++i; startCol += UnicodeTranscript.charWidth(c, textToLinkify.charAt(i)); } else { startCol += UnicodeTranscript.charWidth(c); } if (startCol >= columns) { ++startRow; startCol %= columns; } } endRow = startRow; endCol = startCol; for (int i = spanStart; i < spanEnd; ++i) { char c = textToLinkify.charAt(i); if (Character.isHighSurrogate(c)) { ++i; endCol += UnicodeTranscript.charWidth(c, textToLinkify.charAt(i)); } else { endCol += UnicodeTranscript.charWidth(c); } if (endCol >= columns) { ++endRow; endCol %= columns; } } } //Fill linkRows with the URL where appropriate for(int i=startRow; i <= endRow; ++i) { int runStart = (i == startRow) ? startCol: 0; int runEnd = (i == endRow) ? endCol : mColumns - 1; Arrays.fill(linkRows[i], runStart, runEnd + 1, url); } } //Add links into the link layer for later retrieval for(int i=0; i<lineCount; ++i) mLinkLayer.put(screenRow + i, linkRows[i]); } return lineCount; } /** * Sends mouse wheel codes to terminal in response to fling. */ private class MouseTrackingFlingRunner implements Runnable { private Scroller mScroller; private int mLastY; private MotionEvent mMotionEvent; public void fling(MotionEvent e, float velocityX, float velocityY) { float SCALE = 0.15f; mScroller.fling(0, 0, -(int) (velocityX * SCALE), -(int) (velocityY * SCALE), 0, 0, -100, 100); mLastY = 0; mMotionEvent = e; post(this); } public void run() { if (mScroller.isFinished()) { return; } // Check whether mouse tracking was turned off during fling. if (!isMouseTrackingActive()) { return; } boolean more = mScroller.computeScrollOffset(); int newY = mScroller.getCurrY(); for (; mLastY < newY; mLastY++) { sendMouseEventCode(mMotionEvent, 65); } for (; mLastY > newY; mLastY--) { sendMouseEventCode(mMotionEvent, 64); } if (more) { post(this); } } }; private MouseTrackingFlingRunner mMouseTrackingFlingRunner = new MouseTrackingFlingRunner(); private float mScrollRemainder; private TermKeyListener mKeyListener; private String mImeBuffer = ""; /** * Our message handler class. Implements a periodic callback. */ private final Handler mHandler = new Handler(); /** * Called by the TermSession when the contents of the view need updating */ private UpdateCallback mUpdateNotify = new UpdateCallback() { public void onUpdate() { if ( mIsSelectingText ) { int rowShift = mEmulator.getScrollCounter(); mSelY1 -= rowShift; mSelY2 -= rowShift; mSelYAnchor -= rowShift; } mEmulator.clearScrollCounter(); ensureCursorVisible(); invalidate(); } }; /** * Create an <code>EmulatorView</code> for a {@link TermSession}. * * @param context The {@link Context} for the view. * @param session The {@link TermSession} this view will be displaying. * @param metrics The {@link DisplayMetrics} of the screen on which the view * will be displayed. */ public EmulatorView(Context context, TermSession session, DisplayMetrics metrics) { super(context); attachSession(session); setDensity(metrics); commonConstructor(context); } /** * Constructor called when inflating this view from XML. * <p> * You should call {@link #attachSession attachSession} and {@link * #setDensity setDensity} before using an <code>EmulatorView</code> created * using this constructor. */ public EmulatorView(Context context, AttributeSet attrs) { super(context, attrs); commonConstructor(context); } /** * Constructor called when inflating this view from XML with a * default style set. * <p> * You should call {@link #attachSession attachSession} and {@link * #setDensity setDensity} before using an <code>EmulatorView</code> created * using this constructor. */ public EmulatorView(Context context, AttributeSet attrs, int defStyle) { super(context, attrs, defStyle); commonConstructor(context); } private void commonConstructor(Context context) { // TODO: See if we want to use the API level 11 constructor to get new flywheel feature. mScroller = new Scroller(context); mMouseTrackingFlingRunner.mScroller = new Scroller(context); } /** * Attach a {@link TermSession} to this view. * * @param session The {@link TermSession} this view will be displaying. */ public void attachSession(TermSession session) { mTextRenderer = null; mForegroundPaint = new Paint(); mBackgroundPaint = new Paint(); mTopRow = 0; mLeftColumn = 0; mGestureDetector = new GestureDetector(this); // mGestureDetector.setIsLongpressEnabled(false); setVerticalScrollBarEnabled(true); setFocusable(true); setFocusableInTouchMode(true); mTermSession = session; mKeyListener = new TermKeyListener(session); session.setKeyListener(mKeyListener); // Do init now if it was deferred until a TermSession was attached if (mDeferInit) { mDeferInit = false; mKnownSize = true; initialize(); } } /** * Update the screen density for the screen on which the view is displayed. * * @param metrics The {@link DisplayMetrics} of the screen. */ public void setDensity(DisplayMetrics metrics) { if (mDensity == 0) { // First time we've known the screen density, so update font size mTextSize = (int) (mTextSize * metrics.density); } mDensity = metrics.density; mScaledDensity = metrics.scaledDensity; } /** * Inform the view that it is now visible on screen. */ public void onResume() { updateSize(false); if (mCursorBlink != 0) { mHandler.postDelayed(mBlinkCursor, CURSOR_BLINK_PERIOD); } if (mKeyListener != null) { mKeyListener.onResume(); } } /** * Inform the view that it is no longer visible on the screen. */ public void onPause() { if (mCursorBlink != 0) { mHandler.removeCallbacks(mBlinkCursor); } if (mKeyListener != null) { mKeyListener.onPause(); } } /** * Set this <code>EmulatorView</code>'s color scheme. * * @param scheme The {@link ColorScheme} to use (use null for the default * scheme). * @see TermSession#setColorScheme * @see ColorScheme */ public void setColorScheme(ColorScheme scheme) { if (scheme == null) { mColorScheme = BaseTextRenderer.defaultColorScheme; } else { mColorScheme = scheme; } updateText(); } @Override public boolean onCheckIsTextEditor() { return true; } @Override public InputConnection onCreateInputConnection(EditorInfo outAttrs) { outAttrs.inputType = mUseCookedIme ? EditorInfo.TYPE_CLASS_TEXT : EditorInfo.TYPE_NULL; return new BaseInputConnection(this, true) { /** * Used to handle composing text requests */ private int mCursor; private int mComposingTextStart; private int mComposingTextEnd; private int mSelectedTextStart; private int mSelectedTextEnd; private void sendText(CharSequence text) { int n = text.length(); char c; try { for(int i = 0; i < n; i++) { c = text.charAt(i); if (Character.isHighSurrogate(c)) { int codePoint; if (++i < n) { codePoint = Character.toCodePoint(c, text.charAt(i)); } else { // Unicode Replacement Glyph, aka white question mark in black diamond. codePoint = '\ufffd'; } mapAndSend(codePoint); } else { mapAndSend(c); } } } catch (IOException e) { Log.e(TAG, "error writing ", e); } } private void mapAndSend(int c) throws IOException { int result = mKeyListener.mapControlChar(c); if (result < TermKeyListener.KEYCODE_OFFSET) { mTermSession.write(result); } else { mKeyListener.handleKeyCode(result - TermKeyListener.KEYCODE_OFFSET, null, getKeypadApplicationMode()); } clearSpecialKeyStatus(); } public boolean beginBatchEdit() { if (LOG_IME) { Log.w(TAG, "beginBatchEdit"); } setImeBuffer(""); mCursor = 0; mComposingTextStart = 0; mComposingTextEnd = 0; return true; } public boolean clearMetaKeyStates(int arg0) { if (LOG_IME) { Log.w(TAG, "clearMetaKeyStates " + arg0); } return false; } public boolean commitCompletion(CompletionInfo arg0) { if (LOG_IME) { Log.w(TAG, "commitCompletion " + arg0); } return false; } public boolean endBatchEdit() { if (LOG_IME) { Log.w(TAG, "endBatchEdit"); } return true; } public boolean finishComposingText() { if (LOG_IME) { Log.w(TAG, "finishComposingText"); } sendText(mImeBuffer); setImeBuffer(""); mComposingTextStart = 0; mComposingTextEnd = 0; mCursor = 0; return true; } public int getCursorCapsMode(int reqModes) { if (LOG_IME) { Log.w(TAG, "getCursorCapsMode(" + reqModes + ")"); } int mode = 0; if ((reqModes & TextUtils.CAP_MODE_CHARACTERS) != 0) { mode |= TextUtils.CAP_MODE_CHARACTERS; } return mode; } public ExtractedText getExtractedText(ExtractedTextRequest arg0, int arg1) { if (LOG_IME) { Log.w(TAG, "getExtractedText" + arg0 + "," + arg1); } return null; } public CharSequence getTextAfterCursor(int n, int flags) { if (LOG_IME) { Log.w(TAG, "getTextAfterCursor(" + n + "," + flags + ")"); } int len = Math.min(n, mImeBuffer.length() - mCursor); if (len <= 0 || mCursor < 0 || mCursor >= mImeBuffer.length()) { return ""; } return mImeBuffer.substring(mCursor, mCursor + len); } public CharSequence getTextBeforeCursor(int n, int flags) { if (LOG_IME) { Log.w(TAG, "getTextBeforeCursor(" + n + "," + flags + ")"); } int len = Math.min(n, mCursor); if (len <= 0 || mCursor < 0 || mCursor >= mImeBuffer.length()) { return ""; } return mImeBuffer.substring(mCursor-len, mCursor); } public boolean performContextMenuAction(int arg0) { if (LOG_IME) { Log.w(TAG, "performContextMenuAction" + arg0); } return true; } public boolean performPrivateCommand(String arg0, Bundle arg1) { if (LOG_IME) { Log.w(TAG, "performPrivateCommand" + arg0 + "," + arg1); } return true; } public boolean reportFullscreenMode(boolean arg0) { if (LOG_IME) { Log.w(TAG, "reportFullscreenMode" + arg0); } return true; } public boolean commitCorrection (CorrectionInfo correctionInfo) { if (LOG_IME) { Log.w(TAG, "commitCorrection"); } return true; } public boolean commitText(CharSequence text, int newCursorPosition) { if (LOG_IME) { Log.w(TAG, "commitText(\"" + text + "\", " + newCursorPosition + ")"); } clearComposingText(); sendText(text); setImeBuffer(""); mCursor = 0; return true; } private void clearComposingText() { int len = mImeBuffer.length(); if (mComposingTextStart > len || mComposingTextEnd > len) { mComposingTextEnd = mComposingTextStart = 0; return; } setImeBuffer(mImeBuffer.substring(0, mComposingTextStart) + mImeBuffer.substring(mComposingTextEnd)); if (mCursor < mComposingTextStart) { // do nothing } else if (mCursor < mComposingTextEnd) { mCursor = mComposingTextStart; } else { mCursor -= mComposingTextEnd - mComposingTextStart; } mComposingTextEnd = mComposingTextStart = 0; } public boolean deleteSurroundingText(int leftLength, int rightLength) { if (LOG_IME) { Log.w(TAG, "deleteSurroundingText(" + leftLength + "," + rightLength + ")"); } if (leftLength > 0) { for (int i = 0; i < leftLength; i++) { sendKeyEvent( new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DEL)); } } else if ((leftLength == 0) && (rightLength == 0)) { // Delete key held down / repeating sendKeyEvent( new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DEL)); } // TODO: handle forward deletes. return true; } public boolean performEditorAction(int actionCode) { if (LOG_IME) { Log.w(TAG, "performEditorAction(" + actionCode + ")"); } if (actionCode == EditorInfo.IME_ACTION_UNSPECIFIED) { // The "return" key has been pressed on the IME. sendText("\r"); } return true; } public boolean sendKeyEvent(KeyEvent event) { if (LOG_IME) { Log.w(TAG, "sendKeyEvent(" + event + ")"); } // Some keys are sent here rather than to commitText. // In particular, del and the digit keys are sent here. // (And I have reports that the HTC Magic also sends Return here.) // As a bit of defensive programming, handle every key. dispatchKeyEvent(event); return true; } public boolean setComposingText(CharSequence text, int newCursorPosition) { if (LOG_IME) { Log.w(TAG, "setComposingText(\"" + text + "\", " + newCursorPosition + ")"); } int len = mImeBuffer.length(); if (mComposingTextStart > len || mComposingTextEnd > len) { return false; } setImeBuffer(mImeBuffer.substring(0, mComposingTextStart) + text + mImeBuffer.substring(mComposingTextEnd)); mComposingTextEnd = mComposingTextStart + text.length(); mCursor = newCursorPosition > 0 ? mComposingTextEnd + newCursorPosition - 1 : mComposingTextStart - newCursorPosition; return true; } public boolean setSelection(int start, int end) { if (LOG_IME) { Log.w(TAG, "setSelection" + start + "," + end); } int length = mImeBuffer.length(); if (start == end && start > 0 && start < length) { mSelectedTextStart = mSelectedTextEnd = 0; mCursor = start; } else if (start < end && start > 0 && end < length) { mSelectedTextStart = start; mSelectedTextEnd = end; mCursor = start; } return true; } public boolean setComposingRegion(int start, int end) { if (LOG_IME) { Log.w(TAG, "setComposingRegion " + start + "," + end); } if (start < end && start > 0 && end < mImeBuffer.length()) { clearComposingText(); mComposingTextStart = start; mComposingTextEnd = end; } return true; } public CharSequence getSelectedText(int flags) { if (LOG_IME) { Log.w(TAG, "getSelectedText " + flags); } int len = mImeBuffer.length(); if (mSelectedTextEnd >= len || mSelectedTextStart > mSelectedTextEnd) { return ""; } return mImeBuffer.substring(mSelectedTextStart, mSelectedTextEnd+1); } }; } private void setImeBuffer(String buffer) { if (!buffer.equals(mImeBuffer)) { invalidate(); } mImeBuffer = buffer; } /** * Get the terminal emulator's keypad application mode. */ public boolean getKeypadApplicationMode() { return mEmulator.getKeypadApplicationMode(); } /** * Set a {@link android.view.GestureDetector.OnGestureListener * GestureDetector.OnGestureListener} to receive gestures performed on this * view. Can be used to implement additional * functionality via touch gestures or override built-in gestures. * * @param listener The {@link * android.view.GestureDetector.OnGestureListener * GestureDetector.OnGestureListener} which will receive * gestures. */ public void setExtGestureListener(GestureDetector.OnGestureListener listener) { mExtGestureListener = listener; } /** * Compute the vertical range that the vertical scrollbar represents. */ @Override protected int computeVerticalScrollRange() { return mEmulator.getScreen().getActiveRows(); } /** * Compute the vertical extent of the horizontal scrollbar's thumb within * the vertical range. This value is used to compute the length of the thumb * within the scrollbar's track. */ @Override protected int computeVerticalScrollExtent() { return mRows; } /** * Compute the vertical offset of the vertical scrollbar's thumb within the * horizontal range. This value is used to compute the position of the thumb * within the scrollbar's track. */ @Override protected int computeVerticalScrollOffset() { return mEmulator.getScreen().getActiveRows() + mTopRow - mRows; } /** * Call this to initialize the view. */ private void initialize() { TermSession session = mTermSession; updateText(); mEmulator = session.getEmulator(); session.setUpdateCallback(mUpdateNotify); requestFocus(); } /** * Get the {@link TermSession} corresponding to this view. * * @return The {@link TermSession} object for this view. */ public TermSession getTermSession() { return mTermSession; } /** * Get the width of the visible portion of this view. * * @return The width of the visible portion of this view, in pixels. */ public int getVisibleWidth() { return mVisibleWidth; } /** * Get the height of the visible portion of this view. * * @return The height of the visible portion of this view, in pixels. */ public int getVisibleHeight() { return mVisibleHeight; } /** * Gets the visible number of rows for the view, useful when updating Ptysize with the correct number of rows/columns * @return The rows for the visible number of rows, this is calculate in updateSize(int w, int h), please call * updateSize(true) if the view changed, to get the correct calculation before calling this. */ public int getVisibleRows() { return mVisibleRows; } /** * Gets the visible number of columns for the view, again useful to get when updating PTYsize * @return the columns for the visisble view, please call updateSize(true) to re-calculate this if the view has changed */ public int getVisibleColumns() { return mVisibleColumns; } /** * Page the terminal view (scroll it up or down by <code>delta</code> * screenfuls). * * @param delta The number of screens to scroll. Positive means scroll down, * negative means scroll up. */ public void page(int delta) { mTopRow = Math.min(0, Math.max(-(mEmulator.getScreen() .getActiveTranscriptRows()), mTopRow + mRows * delta)); invalidate(); } /** * Page the terminal view horizontally. * * @param deltaColumns the number of columns to scroll. Positive scrolls to * the right. */ public void pageHorizontal(int deltaColumns) { mLeftColumn = Math.max(0, Math.min(mLeftColumn + deltaColumns, mColumns - mVisibleColumns)); invalidate(); } /** * Sets the text size, which in turn sets the number of rows and columns. * * @param fontSize the new font size, in density-independent pixels. */ public void setTextSize(int fontSize) { mTextSize = (int) (fontSize * mDensity); updateText(); } /** * Sets the IME mode ("cooked" or "raw"). * * @param useCookedIME Whether the IME should be used in cooked mode. */ public void setUseCookedIME(boolean useCookedIME) { mUseCookedIme = useCookedIME; } /** * Returns true if mouse events are being sent as escape sequences to the terminal. */ public boolean isMouseTrackingActive() { return mEmulator.getMouseTrackingMode() != 0 && mMouseTracking; } /** * Send a single mouse event code to the terminal. */ private void sendMouseEventCode(MotionEvent e, int button_code) { int x = (int)(e.getX() / mCharacterWidth) + 1; int y = (int)((e.getY()-mTopOfScreenMargin) / mCharacterHeight) + 1; // Clip to screen, and clip to the limits of 8-bit data. boolean out_of_bounds = x < 1 || y < 1 || x > mColumns || y > mRows || x > 255-32 || y > 255-32; //Log.d(TAG, "mouse button "+x+","+y+","+button_code+",oob="+out_of_bounds); if(button_code < 0 || button_code > 255-32) { Log.e(TAG, "mouse button_code out of range: "+button_code); return; } if(!out_of_bounds) { byte[] data = { '\033', '[', 'M', (byte)(32 + button_code), (byte)(32 + x), (byte)(32 + y) }; mTermSession.write(data, 0, data.length); } } // Begin GestureDetector.OnGestureListener methods public boolean onSingleTapUp(MotionEvent e) { if (mExtGestureListener != null && mExtGestureListener.onSingleTapUp(e)) { return true; } if (isMouseTrackingActive()) { sendMouseEventCode(e, 0); // BTN1 press sendMouseEventCode(e, 3); // release } requestFocus(); return true; } public void onLongPress(MotionEvent e) { // XXX hook into external gesture listener showContextMenu(); } public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) { if (mExtGestureListener != null && mExtGestureListener.onScroll(e1, e2, distanceX, distanceY)) { return true; } distanceY += mScrollRemainder; int deltaRows = (int) (distanceY / mCharacterHeight); mScrollRemainder = distanceY - deltaRows * mCharacterHeight; if (isMouseTrackingActive()) { // Send mouse wheel events to terminal. for (; deltaRows>0; deltaRows--) { sendMouseEventCode(e1, 65); } for (; deltaRows<0; deltaRows++) { sendMouseEventCode(e1, 64); } return true; } mTopRow = Math.min(0, Math.max(-(mEmulator.getScreen() .getActiveTranscriptRows()), mTopRow + deltaRows)); invalidate(); return true; } public void onSingleTapConfirmed(MotionEvent e) { } public boolean onJumpTapDown(MotionEvent e1, MotionEvent e2) { // Scroll to bottom mTopRow = 0; invalidate(); return true; } public boolean onJumpTapUp(MotionEvent e1, MotionEvent e2) { // Scroll to top mTopRow = -mEmulator.getScreen().getActiveTranscriptRows(); invalidate(); return true; } public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) { if (mExtGestureListener != null && mExtGestureListener.onFling(e1, e2, velocityX, velocityY)) { return true; } mScrollRemainder = 0.0f; if (isMouseTrackingActive()) { mMouseTrackingFlingRunner.fling(e1, velocityX, velocityY); } else { float SCALE = 0.25f; mScroller.fling(0, mTopRow, -(int) (velocityX * SCALE), -(int) (velocityY * SCALE), 0, 0, -mEmulator.getScreen().getActiveTranscriptRows(), 0); // onScroll(e1, e2, 0.1f * velocityX, -0.1f * velocityY); post(mFlingRunner); } return true; } public void onShowPress(MotionEvent e) { if (mExtGestureListener != null) { mExtGestureListener.onShowPress(e); } } public boolean onDown(MotionEvent e) { if (mExtGestureListener != null && mExtGestureListener.onDown(e)) { return true; } mScrollRemainder = 0.0f; return true; } // End GestureDetector.OnGestureListener methods @Override public boolean onTouchEvent(MotionEvent ev) { if (mIsSelectingText) { return onTouchEventWhileSelectingText(ev); } else { return mGestureDetector.onTouchEvent(ev); } } private boolean onTouchEventWhileSelectingText(MotionEvent ev) { int action = ev.getAction(); int cx = (int)(ev.getX() / mCharacterWidth); int cy = Math.max(0, (int)((ev.getY() + SELECT_TEXT_OFFSET_Y * mScaledDensity) / mCharacterHeight) + mTopRow); switch (action) { case MotionEvent.ACTION_DOWN: mSelXAnchor = cx; mSelYAnchor = cy; mSelX1 = cx; mSelY1 = cy; mSelX2 = mSelX1; mSelY2 = mSelY1; break; case MotionEvent.ACTION_MOVE: case MotionEvent.ACTION_UP: int minx = Math.min(mSelXAnchor, cx); int maxx = Math.max(mSelXAnchor, cx); int miny = Math.min(mSelYAnchor, cy); int maxy = Math.max(mSelYAnchor, cy); mSelX1 = minx; mSelY1 = miny; mSelX2 = maxx; mSelY2 = maxy; if (action == MotionEvent.ACTION_UP) { ClipboardManagerCompat clip = ClipboardManagerCompatFactory .getManager(getContext().getApplicationContext()); clip.setText(getSelectedText().trim()); toggleSelectingText(); } invalidate(); break; default: toggleSelectingText(); invalidate(); break; } return true; } /** * Called when a key is pressed in the view. * * @param keyCode The keycode of the key which was pressed. * @param event A {@link KeyEvent} describing the event. * @return Whether the event was handled. */ @Override public boolean onKeyDown(int keyCode, KeyEvent event) { if (LOG_KEY_EVENTS) { Log.w(TAG, "onKeyDown " + keyCode); } if (handleControlKey(keyCode, true)) { return true; } else if (handleFnKey(keyCode, true)) { return true; } else if (isSystemKey(keyCode, event)) { if (! isInterceptedSystemKey(keyCode) ) { // Don't intercept the system keys return super.onKeyDown(keyCode, event); } } // Translate the keyCode into an ASCII character. try { int oldCombiningAccent = mKeyListener.getCombiningAccent(); int oldCursorMode = mKeyListener.getCursorMode(); mKeyListener.keyDown(keyCode, event, getKeypadApplicationMode(), TermKeyListener.isEventFromToggleDevice(event)); if (mKeyListener.getCombiningAccent() != oldCombiningAccent || mKeyListener.getCursorMode() != oldCursorMode) { invalidate(); } } catch (IOException e) { // Ignore I/O exceptions } return true; } /** Do we want to intercept this system key? */ private boolean isInterceptedSystemKey(int keyCode) { return keyCode == KeyEvent.KEYCODE_BACK && mBackKeySendsCharacter; } /** * 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 (LOG_KEY_EVENTS) { Log.w(TAG, "onKeyUp " + keyCode); } if (handleControlKey(keyCode, false)) { return true; } else if (handleFnKey(keyCode, false)) { return true; } else if (isSystemKey(keyCode, event)) { // Don't intercept the system keys if ( ! isInterceptedSystemKey(keyCode) ) { return super.onKeyUp(keyCode, event); } } mKeyListener.keyUp(keyCode, event); clearSpecialKeyStatus(); return true; } @Override public boolean onKeyPreIme(int keyCode, KeyEvent event) { if (sTrapAltAndMeta) { boolean altEsc = mKeyListener.getAltSendsEsc(); boolean altOn = (event.getMetaState() & KeyEvent.META_ALT_ON) != 0; boolean metaOn = (event.getMetaState() & KeyEvent.META_META_ON) != 0; boolean altPressed = (keyCode == KeyEvent.KEYCODE_ALT_LEFT) || (keyCode == KeyEvent.KEYCODE_ALT_RIGHT); boolean altActive = mKeyListener.isAltActive(); if (altEsc && (altOn || altPressed || altActive || metaOn)) { if (event.getAction() == KeyEvent.ACTION_DOWN) { return onKeyDown(keyCode, event); } else { return onKeyUp(keyCode, event); } } } if (handleHardwareControlKey(keyCode, event)) { return true; } if (mKeyListener.isCtrlActive()) { if (event.getAction() == KeyEvent.ACTION_DOWN) { return onKeyDown(keyCode, event); } else { return onKeyUp(keyCode, event); } } return super.onKeyPreIme(keyCode, event); }; private boolean handleControlKey(int keyCode, boolean down) { if (keyCode == mControlKeyCode) { if (LOG_KEY_EVENTS) { Log.w(TAG, "handleControlKey " + keyCode); } mKeyListener.handleControlKey(down); invalidate(); return true; } return false; } private boolean handleHardwareControlKey(int keyCode, KeyEvent event) { if (keyCode == KeycodeConstants.KEYCODE_CTRL_LEFT || keyCode == KeycodeConstants.KEYCODE_CTRL_RIGHT) { if (LOG_KEY_EVENTS) { Log.w(TAG, "handleHardwareControlKey " + keyCode); } boolean down = event.getAction() == KeyEvent.ACTION_DOWN; mKeyListener.handleHardwareControlKey(down); invalidate(); return true; } return false; } private boolean handleFnKey(int keyCode, boolean down) { if (keyCode == mFnKeyCode) { if (LOG_KEY_EVENTS) { Log.w(TAG, "handleFnKey " + keyCode); } mKeyListener.handleFnKey(down); invalidate(); return true; } return false; } private boolean isSystemKey(int keyCode, KeyEvent event) { return event.isSystem(); } private void clearSpecialKeyStatus() { if (mIsControlKeySent) { mIsControlKeySent = false; mKeyListener.handleControlKey(false); invalidate(); } if (mIsFnKeySent) { mIsFnKeySent = false; mKeyListener.handleFnKey(false); invalidate(); } } private void updateText() { ColorScheme scheme = mColorScheme; if (mTextSize > 0) { mTextRenderer = new PaintRenderer(mTextSize, scheme); } else { mTextRenderer = new Bitmap4x8FontRenderer(getResources(), scheme); } mForegroundPaint.setColor(scheme.getForeColor()); mBackgroundPaint.setColor(scheme.getBackColor()); mCharacterWidth = mTextRenderer.getCharacterWidth(); mCharacterHeight = mTextRenderer.getCharacterHeight(); updateSize(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) { if (mTermSession == null) { // Not ready, defer until TermSession is attached mDeferInit = true; return; } if (!mKnownSize) { mKnownSize = true; initialize(); } else { updateSize(false); } } private void updateSize(int w, int h) { mColumns = Math.max(1, (int) (((float) w) / mCharacterWidth)); mVisibleColumns = Math.max(1, (int) (((float) mVisibleWidth) / mCharacterWidth)); mTopOfScreenMargin = mTextRenderer.getTopMargin(); mRows = Math.max(1, (h - mTopOfScreenMargin) / mCharacterHeight); mVisibleRows = Math.max(1, (mVisibleHeight - mTopOfScreenMargin) / mCharacterHeight); mTermSession.updateSize(mColumns, mRows); // Reset our paging: mTopRow = 0; mLeftColumn = 0; invalidate(); } /** * Update the view's idea of its size. * * @param force Whether a size adjustment should be performed even if the * view's size has not changed. */ public void updateSize(boolean force) { //Need to clear saved links on each display refresh mLinkLayer.clear(); if (mKnownSize) { int w = getWidth(); int h = getHeight(); // Log.w("Term", "(" + w + ", " + h + ")"); if (force || w != mVisibleWidth || h != mVisibleHeight) { mVisibleWidth = w; mVisibleHeight = h; updateSize(mVisibleWidth, mVisibleHeight); } } } /** * Draw the view to the provided {@link Canvas}. * * @param canvas The {@link Canvas} to draw the view to. */ @Override protected void onDraw(Canvas canvas) { updateSize(false); if (mEmulator == null) { // Not ready yet return; } int w = getWidth(); int h = getHeight(); boolean reverseVideo = mEmulator.getReverseVideo(); mTextRenderer.setReverseVideo(reverseVideo); Paint backgroundPaint = reverseVideo ? mForegroundPaint : mBackgroundPaint; canvas.drawRect(0, 0, w, h, backgroundPaint); float x = -mLeftColumn * mCharacterWidth; float y = mCharacterHeight + mTopOfScreenMargin; int endLine = mTopRow + mRows; int cx = mEmulator.getCursorCol(); int cy = mEmulator.getCursorRow(); boolean cursorVisible = mCursorVisible && mEmulator.getShowCursor(); String effectiveImeBuffer = mImeBuffer; int combiningAccent = mKeyListener.getCombiningAccent(); if (combiningAccent != 0) { effectiveImeBuffer += String.valueOf((char) combiningAccent); } int cursorStyle = mKeyListener.getCursorMode(); int linkLinesToSkip = 0; //for multi-line links for (int i = mTopRow; i < endLine; i++) { int cursorX = -1; if (i == cy && cursorVisible) { cursorX = cx; } int selx1 = -1; int selx2 = -1; if ( i >= mSelY1 && i <= mSelY2 ) { if ( i == mSelY1 ) { selx1 = mSelX1; } if ( i == mSelY2 ) { selx2 = mSelX2; } else { selx2 = mColumns; } } mEmulator.getScreen().drawText(i, canvas, x, y, mTextRenderer, cursorX, selx1, selx2, effectiveImeBuffer, cursorStyle); y += mCharacterHeight; //if no lines to skip, create links for the line being drawn if(linkLinesToSkip == 0) linkLinesToSkip = createLinks(i); //createLinks always returns at least 1 --linkLinesToSkip; } } private void ensureCursorVisible() { mTopRow = 0; if (mVisibleColumns > 0) { int cx = mEmulator.getCursorCol(); int visibleCursorX = mEmulator.getCursorCol() - mLeftColumn; if (visibleCursorX < 0) { mLeftColumn = cx; } else if (visibleCursorX >= mVisibleColumns) { mLeftColumn = (cx - mVisibleColumns) + 1; } } } /** * Toggle text selection mode in the view. */ public void toggleSelectingText() { mIsSelectingText = ! mIsSelectingText; setVerticalScrollBarEnabled( ! mIsSelectingText ); if ( ! mIsSelectingText ) { mSelX1 = -1; mSelY1 = -1; mSelX2 = -1; mSelY2 = -1; } } /** * Whether the view is currently in text selection mode. */ public boolean getSelectingText() { return mIsSelectingText; } /** * Get selected text. * * @return A {@link String} with the selected text. */ public String getSelectedText() { return mEmulator.getSelectedText(mSelX1, mSelY1, mSelX2, mSelY2); } /** * Send a Ctrl key event to the terminal. */ public void sendControlKey() { mIsControlKeySent = true; mKeyListener.handleControlKey(true); invalidate(); } /** * Send an Fn key event to the terminal. The Fn modifier key can be used to * generate various special characters and escape codes. */ public void sendFnKey() { mIsFnKeySent = true; mKeyListener.handleFnKey(true); invalidate(); } /** * Set the key code to be sent when the Back key is pressed. */ public void setBackKeyCharacter(int keyCode) { mKeyListener.setBackKeyCharacter(keyCode); mBackKeySendsCharacter = (keyCode != 0); } /** * Set whether to prepend the ESC keycode to the character when when pressing * the ALT Key. * @param flag */ public void setAltSendsEsc(boolean flag) { mKeyListener.setAltSendsEsc(flag); } /** * Set the keycode corresponding to the Ctrl key. */ public void setControlKeyCode(int keyCode) { mControlKeyCode = keyCode; } /** * Set the keycode corresponding to the Fn key. */ public void setFnKeyCode(int keyCode) { mFnKeyCode = keyCode; } public void setTermType(String termType) { mKeyListener.setTermType(termType); } /** * Set whether mouse events should be sent to the terminal as escape codes. */ public void setMouseTracking(boolean flag) { mMouseTracking = flag; } /** * Get the URL for the link displayed at the specified screen coordinates. * * @param x The x coordinate being queried (from 0 to screen width) * @param y The y coordinate being queried (from 0 to screen height) * @return The URL for the link at the specified screen coordinates, or * null if no link exists there. */ public String getURLat(float x, float y) { float w = getWidth(); float h = getHeight(); //Check for division by zero //If width or height is zero, there are probably no links around, so return null. if(w == 0 || h == 0) return null; //Get fraction of total screen float x_pos = x / w; float y_pos = y / h; //Convert to integer row/column index int row = (int)Math.floor(y_pos * mRows); int col = (int)Math.floor(x_pos * mColumns); //Grab row from link layer URLSpan [] linkRow = mLinkLayer.get(row); URLSpan link; //If row exists, and link exists at column, return it if(linkRow != null && (link = linkRow[col]) != null) return link.getURL(); else return null; } }