package com.termux.view.textselection; import android.content.ClipboardManager; import android.content.Context; import android.graphics.Rect; import android.os.Build; import android.text.TextUtils; import android.view.ActionMode; import android.view.Menu; import android.view.MenuItem; import android.view.MotionEvent; import android.view.View; import androidx.annotation.Nullable; import com.termux.terminal.TerminalBuffer; import com.termux.terminal.WcWidth; import com.termux.view.R; import com.termux.view.TerminalView; public class TextSelectionCursorController implements CursorController { private final TerminalView terminalView; private final TextSelectionHandleView mStartHandle, mEndHandle; private String mStoredSelectedText; private boolean mIsSelectingText = false; private long mShowStartTime = System.currentTimeMillis(); private final int mHandleHeight; public int mSelX1 = -1, mSelX2 = -1, mSelY1 = -1, mSelY2 = -1; private ActionMode mActionMode; public final int ACTION_COPY = 1; public final int ACTION_PASTE = 2; public final int ACTION_MORE = 3; public TextSelectionCursorController(TerminalView terminalView) { this.terminalView = terminalView; mStartHandle = new TextSelectionHandleView(terminalView, this, TextSelectionHandleView.LEFT); mEndHandle = new TextSelectionHandleView(terminalView, this, TextSelectionHandleView.RIGHT); mHandleHeight = Math.max(mStartHandle.getHandleHeight(), mEndHandle.getHandleHeight()); } @Override public void show(MotionEvent event) { setInitialTextSelectionPosition(event); mStartHandle.positionAtCursor(mSelX1, mSelY1, true); mEndHandle.positionAtCursor(mSelX2 + 1, mSelY2, true); setActionModeCallBacks(); mShowStartTime = System.currentTimeMillis(); mIsSelectingText = true; } @Override public boolean hide() { if (!isActive()) return false; // prevent hide calls right after a show call, like long pressing the down key // 300ms seems long enough that it wouldn't cause hide problems if action button // is quickly clicked after the show, otherwise decrease it if (System.currentTimeMillis() - mShowStartTime < 300) { return false; } mStartHandle.hide(); mEndHandle.hide(); if (mActionMode != null) { // This will hide the TextSelectionCursorController mActionMode.finish(); } mSelX1 = mSelY1 = mSelX2 = mSelY2 = -1; mIsSelectingText = false; return true; } @Override public void render() { if (!isActive()) return; mStartHandle.positionAtCursor(mSelX1, mSelY1, false); mEndHandle.positionAtCursor(mSelX2 + 1, mSelY2, false); if (mActionMode != null) { mActionMode.invalidate(); } } public void setInitialTextSelectionPosition(MotionEvent event) { int[] columnAndRow = terminalView.getColumnAndRow(event, true); mSelX1 = mSelX2 = columnAndRow[0]; mSelY1 = mSelY2 = columnAndRow[1]; TerminalBuffer screen = terminalView.mEmulator.getScreen(); if (!" ".equals(screen.getSelectedText(mSelX1, mSelY1, mSelX1, mSelY1))) { // Selecting something other than whitespace. Expand to word. while (mSelX1 > 0 && !"".equals(screen.getSelectedText(mSelX1 - 1, mSelY1, mSelX1 - 1, mSelY1))) { mSelX1--; } while (mSelX2 < terminalView.mEmulator.mColumns - 1 && !"".equals(screen.getSelectedText(mSelX2 + 1, mSelY1, mSelX2 + 1, mSelY1))) { mSelX2++; } } } public void setActionModeCallBacks() { final ActionMode.Callback callback = new ActionMode.Callback() { @Override public boolean onCreateActionMode(ActionMode mode, Menu menu) { int show = MenuItem.SHOW_AS_ACTION_IF_ROOM | MenuItem.SHOW_AS_ACTION_WITH_TEXT; ClipboardManager clipboard = (ClipboardManager) terminalView.getContext().getSystemService(Context.CLIPBOARD_SERVICE); menu.add(Menu.NONE, ACTION_COPY, Menu.NONE, R.string.copy_text).setShowAsAction(show); menu.add(Menu.NONE, ACTION_PASTE, Menu.NONE, R.string.paste_text).setEnabled(clipboard != null && clipboard.hasPrimaryClip()).setShowAsAction(show); menu.add(Menu.NONE, ACTION_MORE, Menu.NONE, R.string.text_selection_more); return true; } @Override public boolean onPrepareActionMode(ActionMode mode, Menu menu) { return false; } @Override public boolean onActionItemClicked(ActionMode mode, MenuItem item) { if (!isActive()) { // Fix issue where the dialog is pressed while being dismissed. return true; } switch (item.getItemId()) { case ACTION_COPY: String selectedText = getSelectedText(); terminalView.mTermSession.onCopyTextToClipboard(selectedText); terminalView.stopTextSelectionMode(); break; case ACTION_PASTE: terminalView.stopTextSelectionMode(); terminalView.mTermSession.onPasteTextFromClipboard(); break; case ACTION_MORE: // We first store the selected text in case TerminalViewClient needs the // selected text before MORE button was pressed since we are going to // stop selection mode mStoredSelectedText = getSelectedText(); // The text selection needs to be stopped before showing context menu, // otherwise handles will show above popup terminalView.stopTextSelectionMode(); terminalView.showContextMenu(); break; } return true; } @Override public void onDestroyActionMode(ActionMode mode) { } }; mActionMode = terminalView.startActionMode(new ActionMode.Callback2() { @Override public boolean onCreateActionMode(ActionMode mode, Menu menu) { return callback.onCreateActionMode(mode, menu); } @Override public boolean onPrepareActionMode(ActionMode mode, Menu menu) { return false; } @Override public boolean onActionItemClicked(ActionMode mode, MenuItem item) { return callback.onActionItemClicked(mode, item); } @Override public void onDestroyActionMode(ActionMode mode) { // Ignore. } @Override public void onGetContentRect(ActionMode mode, View view, Rect outRect) { int x1 = Math.round(mSelX1 * terminalView.mRenderer.getFontWidth()); int x2 = Math.round(mSelX2 * terminalView.mRenderer.getFontWidth()); int y1 = Math.round((mSelY1 - 1 - terminalView.getTopRow()) * terminalView.mRenderer.getFontLineSpacing()); int y2 = Math.round((mSelY2 + 1 - terminalView.getTopRow()) * terminalView.mRenderer.getFontLineSpacing()); if (x1 > x2) { int tmp = x1; x1 = x2; x2 = tmp; } int terminalBottom = terminalView.getBottom(); int top = y1 + mHandleHeight; int bottom = y2 + mHandleHeight; if (top > terminalBottom) top = terminalBottom; if (bottom > terminalBottom) bottom = terminalBottom; outRect.set(x1, top, x2, bottom); } }, ActionMode.TYPE_FLOATING); } @Override public void updatePosition(TextSelectionHandleView handle, int x, int y) { TerminalBuffer screen = terminalView.mEmulator.getScreen(); final int scrollRows = screen.getActiveRows() - terminalView.mEmulator.mRows; if (handle == mStartHandle) { mSelX1 = terminalView.getCursorX(x); mSelY1 = terminalView.getCursorY(y); if (mSelX1 < 0) { mSelX1 = 0; } if (mSelY1 < -scrollRows) { mSelY1 = -scrollRows; } else if (mSelY1 > terminalView.mEmulator.mRows - 1) { mSelY1 = terminalView.mEmulator.mRows - 1; } if (mSelY1 > mSelY2) { mSelY1 = mSelY2; } if (mSelY1 == mSelY2 && mSelX1 > mSelX2) { mSelX1 = mSelX2; } if (!terminalView.mEmulator.isAlternateBufferActive()) { int topRow = terminalView.getTopRow(); if (mSelY1 <= topRow) { topRow--; if (topRow < -scrollRows) { topRow = -scrollRows; } } else if (mSelY1 >= topRow + terminalView.mEmulator.mRows) { topRow++; if (topRow > 0) { topRow = 0; } } terminalView.setTopRow(topRow); } mSelX1 = getValidCurX(screen, mSelY1, mSelX1); } else { mSelX2 = terminalView.getCursorX(x); mSelY2 = terminalView.getCursorY(y); if (mSelX2 < 0) { mSelX2 = 0; } if (mSelY2 < -scrollRows) { mSelY2 = -scrollRows; } else if (mSelY2 > terminalView.mEmulator.mRows - 1) { mSelY2 = terminalView.mEmulator.mRows - 1; } if (mSelY1 > mSelY2) { mSelY2 = mSelY1; } if (mSelY1 == mSelY2 && mSelX1 > mSelX2) { mSelX2 = mSelX1; } if (!terminalView.mEmulator.isAlternateBufferActive()) { int topRow = terminalView.getTopRow(); if (mSelY2 <= topRow) { topRow--; if (topRow < -scrollRows) { topRow = -scrollRows; } } else if (mSelY2 >= topRow + terminalView.mEmulator.mRows) { topRow++; if (topRow > 0) { topRow = 0; } } terminalView.setTopRow(topRow); } mSelX2 = getValidCurX(screen, mSelY2, mSelX2); } terminalView.invalidate(); } private int getValidCurX(TerminalBuffer screen, int cy, int cx) { String line = screen.getSelectedText(0, cy, cx, cy); if (!TextUtils.isEmpty(line)) { int col = 0; for (int i = 0, len = line.length(); i < len; i++) { char ch1 = line.charAt(i); if (ch1 == 0) { break; } int wc; if (Character.isHighSurrogate(ch1) && i + 1 < len) { char ch2 = line.charAt(++i); wc = WcWidth.width(Character.toCodePoint(ch1, ch2)); } else { wc = WcWidth.width(ch1); } final int cend = col + wc; if (cx > col && cx < cend) { return cend; } if (cend == col) { return col; } col = cend; } } return cx; } public void decrementYTextSelectionCursors(int decrement) { mSelY1 -= decrement; mSelY2 -= decrement; } public boolean onTouchEvent(MotionEvent event) { return false; } public void onTouchModeChanged(boolean isInTouchMode) { if (!isInTouchMode) { terminalView.stopTextSelectionMode(); } } @Override public void onDetached() { } @Override public boolean isActive() { return mIsSelectingText; } /** Get the currently selected text. */ public String getSelectedText() { return terminalView.mEmulator.getSelectedText(mSelX1, mSelY1, mSelX2, mSelY2); } /** Get the selected text stored before "MORE" button was pressed on the context menu. */ @Nullable public String getStoredSelectedText() { return mStoredSelectedText; } /** Unset the selected text stored before "MORE" button was pressed on the context menu. */ public void unsetStoredSelectedText() { mStoredSelectedText = null; } public ActionMode getActionMode() { return mActionMode; } /** * @return true if this controller is currently used to move the start selection. */ public boolean isSelectionStartDragged() { return mStartHandle.isDragging(); } /** * @return true if this controller is currently used to move the end selection. */ public boolean isSelectionEndDragged() { return mEndHandle.isDragging(); } }