From 381c4b65027c62fa87db6038fe6de5f4e46fe990 Mon Sep 17 00:00:00 2001 From: Ryan Hansberry Date: Fri, 9 Oct 2015 14:05:17 -0700 Subject: TerminalView: Move TextView logic that allows text selection into its own class, which allows scrolling selection. --- .../connectbot/util/TerminalTextViewOverlay.java | 368 +++++++++++++++++++++ 1 file changed, 368 insertions(+) create mode 100644 app/src/main/java/org/connectbot/util/TerminalTextViewOverlay.java (limited to 'app/src/main/java/org/connectbot/util') diff --git a/app/src/main/java/org/connectbot/util/TerminalTextViewOverlay.java b/app/src/main/java/org/connectbot/util/TerminalTextViewOverlay.java new file mode 100644 index 0000000..b64351b --- /dev/null +++ b/app/src/main/java/org/connectbot/util/TerminalTextViewOverlay.java @@ -0,0 +1,368 @@ +/* + * ConnectBot: simple, powerful, open-source SSH client for Android + * Copyright 2015 Kenny Root, Jeffrey Sharkey + * + * 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 org.connectbot.util; + +import org.connectbot.R; +import org.connectbot.TerminalView; +import org.connectbot.service.TerminalBridge; + +import android.annotation.TargetApi; +import android.content.Context; +import android.graphics.Color; +import android.graphics.Typeface; +import android.os.Build; +import android.support.annotation.Nullable; +import android.support.v4.view.MotionEventCompat; +import android.text.ClipboardManager; +import android.util.AttributeSet; +import android.view.ActionMode; +import android.view.InputDevice; +import android.view.KeyEvent; +import android.view.Menu; +import android.view.MenuItem; +import android.view.MotionEvent; +import android.view.inputmethod.EditorInfo; +import android.view.inputmethod.InputConnection; +import android.widget.TextView; +import de.mud.terminal.VDUBuffer; +import de.mud.terminal.vt320; + +/** + * Custom TextView {@link TextView} which is intended to (invisibly) be on top of the TerminalView + * (@link TerminalView) in order to allow the user to select and copy the text of the bitmap below. + * + * @author rhansby + */ +@TargetApi(11) +public class TerminalTextViewOverlay extends TextView { + public TerminalView parent; + private String currentSelection = ""; + private ActionMode selectionActionMode; + private ClipboardManager clipboard; + + private int oldScrollY = -1; + + public TerminalTextViewOverlay(Context context) { + super(context); + init(); + } + + public TerminalTextViewOverlay(Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + init(); + } + + @TargetApi(21) + public TerminalTextViewOverlay(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + init(); + } + + private void init() { + setTextColor(Color.TRANSPARENT); + setTypeface(Typeface.MONOSPACE); + setTextIsSelectable(true); + setCustomSelectionActionModeCallback(new TextSelectionActionModeCallback()); + + clipboard = (ClipboardManager) getContext().getSystemService(Context.CLIPBOARD_SERVICE); + } + + // TODO: optimize: instead of always copying the entire buffer, instead append() as needed. + public void refreshTextFromBuffer() { + VDUBuffer vb = parent.bridge.getVDUBuffer(); + + String line = ""; + String buffer = ""; + + int numRows = vb.getBufferSize(); + int numCols = vb.getColumns() - 1; + + for (int r = 0; r < numRows && vb.charArray[r] != null; r++) { + for (int c = 0; c < numCols; c++) { + line += vb.charArray[r][c]; + } + buffer += line.replaceAll("\\s+$", "") + "\n"; + line = ""; + } + + oldScrollY = vb.getWindowBase() * getLineHeight(); + + setText(buffer); + } + + @Override + public boolean onPreDraw() { + boolean superResult = super.onPreDraw(); + + if (oldScrollY >= 0) { + scrollTo(0, oldScrollY); + oldScrollY = -1; + } + + return superResult; + } + + private void closeSelectionActionMode() { + if (selectionActionMode != null) { + selectionActionMode.finish(); + selectionActionMode = null; + } + } + + public void copyCurrentSelectionToClipboard() { + if (currentSelection.length() != 0) { + clipboard.setText(currentSelection); + } + closeSelectionActionMode(); + } + + @Override + protected void onSelectionChanged(int selStart, int selEnd) { + if (selStart <= selEnd) { + currentSelection = getText().toString().substring(selStart, selEnd); + } + super.onSelectionChanged(selStart, selEnd); + } + + @Override + public void scrollTo(int x, int y) { + int lineMultiple = y / getLineHeight(); + + TerminalBridge bridge = parent.bridge; + bridge.buffer.setWindowBase(lineMultiple); + + super.scrollTo(0, lineMultiple * getLineHeight()); + } + + @Override + public boolean onTouchEvent(MotionEvent event) { + if (event.getAction() == MotionEvent.ACTION_DOWN) { + // Selection may be beginning. Sync the TextView with the buffer. + refreshTextFromBuffer(); + } + + // Mouse input is treated differently: + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.ICE_CREAM_SANDWICH && + MotionEventCompat.getSource(event) == InputDevice.SOURCE_MOUSE) { + if (onMouseEvent(event, parent.bridge)) { + return true; + } + parent.viewPager.setPagingEnabled(true); + } else { + parent.onTouchEvent(event); + } + + super.onTouchEvent(event); + + return true; + } + + @Override + @TargetApi(12) + public boolean onGenericMotionEvent(MotionEvent event) { + if ((MotionEventCompat.getSource(event) & InputDevice.SOURCE_CLASS_POINTER) != 0) { + switch (event.getAction()) { + case MotionEvent.ACTION_SCROLL: + // Process scroll wheel movement: + float yDistance = MotionEventCompat.getAxisValue(event, MotionEvent.AXIS_VSCROLL); + + vt320 vtBuffer = (vt320) parent.bridge.buffer; + boolean mouseReport = vtBuffer.isMouseReportEnabled(); + if (mouseReport) { + int row = (int) Math.floor(event.getY() / parent.bridge.charHeight); + int col = (int) Math.floor(event.getX() / parent.bridge.charWidth); + + vtBuffer.mouseWheel( + yDistance > 0, + col, + row, + (event.getMetaState() & KeyEvent.META_CTRL_ON) != 0, + (event.getMetaState() & KeyEvent.META_SHIFT_ON) != 0, + (event.getMetaState() & KeyEvent.META_META_ON) != 0); + return true; + } + } + } + + return super.onGenericMotionEvent(event); + } + + /** + * @param event + * @param bridge + * @return True if the event is handled. + */ + @TargetApi(14) + private boolean onMouseEvent(MotionEvent event, TerminalBridge bridge) { + int row = (int) Math.floor(event.getY() / bridge.charHeight); + int col = (int) Math.floor(event.getX() / bridge.charWidth); + int meta = event.getMetaState(); + boolean shiftOn = (meta & KeyEvent.META_SHIFT_ON) != 0; + vt320 vtBuffer = (vt320) bridge.buffer; + boolean mouseReport = vtBuffer.isMouseReportEnabled(); + + // MouseReport can be "defeated" using the shift key. + if (!mouseReport || shiftOn) { + if (event.getAction() == MotionEvent.ACTION_DOWN) { + if (event.getButtonState() == MotionEvent.BUTTON_TERTIARY) { + // Middle click pastes. + String clip = clipboard.getText().toString(); + bridge.injectString(clip); + return true; + } + + // Begin "selection mode" + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB) { + closeSelectionActionMode(); + } + } else if (event.getAction() == MotionEvent.ACTION_MOVE) { + // In the middle of selection. + + if (selectionActionMode == null) { + selectionActionMode = startActionMode(new TextSelectionActionModeCallback()); + } + + int selectionStart = getSelectionStart(); + int selectionEnd = getSelectionEnd(); + + if (selectionStart > selectionEnd) { + int tempStart = selectionStart; + selectionStart = selectionEnd; + selectionEnd = tempStart; + } + + currentSelection = getText().toString().substring(selectionStart, selectionEnd); + } + } else if (event.getAction() == MotionEvent.ACTION_DOWN) { + parent.viewPager.setPagingEnabled(false); + vtBuffer.mousePressed( + col, row, mouseEventToJavaModifiers(event)); + return true; + } else if (event.getAction() == MotionEvent.ACTION_UP) { + parent.viewPager.setPagingEnabled(true); + vtBuffer.mouseReleased(col, row); + return true; + } else if (event.getAction() == MotionEvent.ACTION_MOVE) { + int buttonState = event.getButtonState(); + int button = (buttonState & MotionEvent.BUTTON_PRIMARY) != 0 ? 0 : + (buttonState & MotionEvent.BUTTON_SECONDARY) != 0 ? 1 : + (buttonState & MotionEvent.BUTTON_TERTIARY) != 0 ? 2 : 3; + vtBuffer.mouseMoved( + button, + col, + row, + (meta & KeyEvent.META_CTRL_ON) != 0, + (meta & KeyEvent.META_SHIFT_ON) != 0, + (meta & KeyEvent.META_META_ON) != 0); + return true; + } + + return false; + } + + /** + * Takes an android mouse event and produces a Java InputEvent modifiers int which can be + * passed to vt320. + * @param mouseEvent The {@link MotionEvent} which should be a mouse click or release. + * @return A Java InputEvent modifier int. See + * http://docs.oracle.com/javase/7/docs/api/java/awt/event/InputEvent.html + */ + @TargetApi(14) + private static int mouseEventToJavaModifiers(MotionEvent mouseEvent) { + if (MotionEventCompat.getSource(mouseEvent) != InputDevice.SOURCE_MOUSE) return 0; + + int mods = 0; + + // See http://docs.oracle.com/javase/7/docs/api/constant-values.html + int buttonState = mouseEvent.getButtonState(); + if ((buttonState & MotionEvent.BUTTON_PRIMARY) != 0) + mods |= 16; + if ((buttonState & MotionEvent.BUTTON_SECONDARY) != 0) + mods |= 8; + if ((buttonState & MotionEvent.BUTTON_TERTIARY) != 0) + mods |= 4; + + // Note: Meta and Ctrl are intentionally swapped here to keep logic in vt320 simple. + int meta = mouseEvent.getMetaState(); + if ((meta & KeyEvent.META_META_ON) != 0) + mods |= 2; + if ((meta & KeyEvent.META_SHIFT_ON) != 0) + mods |= 1; + if ((meta & KeyEvent.META_CTRL_ON) != 0) + mods |= 4; + + return mods; + } + + @Override + public boolean onCheckIsTextEditor() { + return true; + } + + @Override + public InputConnection onCreateInputConnection(EditorInfo outAttrs) { + return parent.onCreateInputConnection(outAttrs); + } + + private class TextSelectionActionModeCallback implements ActionMode.Callback { + private static final int COPY = 0; + private static final int PASTE = 1; + + @Override + public boolean onPrepareActionMode(ActionMode mode, Menu menu) { + return false; + } + + @Override + public boolean onCreateActionMode(ActionMode mode, Menu menu) { + TerminalTextViewOverlay.this.selectionActionMode = mode; + + menu.clear(); + + menu.add(0, COPY, 0, R.string.console_menu_copy) + .setIcon(R.drawable.ic_action_copy) + .setShowAsAction(MenuItem.SHOW_AS_ACTION_WITH_TEXT | MenuItem.SHOW_AS_ACTION_IF_ROOM); + menu.add(0, PASTE, 1, R.string.console_menu_paste) + .setIcon(R.drawable.ic_action_paste) + .setShowAsAction(MenuItem.SHOW_AS_ACTION_WITH_TEXT | MenuItem.SHOW_AS_ACTION_IF_ROOM); + + return true; + } + + @Override + public boolean onActionItemClicked(ActionMode mode, MenuItem item) { + switch (item.getItemId()) { + case COPY: + copyCurrentSelectionToClipboard(); + return true; + case PASTE: + String clip = clipboard.getText().toString(); + TerminalTextViewOverlay.this.parent.bridge.injectString(clip); + mode.finish(); + return true; + } + + return false; + } + + @Override + public void onDestroyActionMode(ActionMode mode) { + } + } +} -- cgit v1.2.3