Repo created

This commit is contained in:
Fr4nz D13trich 2025-11-22 16:41:32 +01:00
parent d22b8dc57b
commit 24b567c524
271 changed files with 39630 additions and 2 deletions

View file

@ -0,0 +1,53 @@
apply plugin: 'com.android.library'
apply plugin: 'maven-publish'
android {
compileSdkVersion project.properties.compileSdkVersion.toInteger()
dependencies {
implementation "androidx.annotation:annotation:1.3.0"
api project(":terminal-emulator")
}
defaultConfig {
minSdkVersion project.properties.minSdkVersion.toInteger()
targetSdkVersion project.properties.targetSdkVersion.toInteger()
testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
}
dependencies {
testImplementation 'junit:junit:4.13.2'
}
task sourceJar(type: Jar) {
from android.sourceSets.main.java.srcDirs
classifier "sources"
}
afterEvaluate {
publishing {
publications {
// Creates a Maven publication called "release".
release(MavenPublication) {
from components.release
groupId = 'com.termux'
artifactId = 'terminal-view'
version = '0.118.0'
artifact(sourceJar)
}
}
}
}

25
terminal-view/proguard-rules.pro vendored Normal file
View file

@ -0,0 +1,25 @@
# Add project specific ProGuard rules here.
# By default, the flags in this file are appended to flags specified
# in /Users/fornwall/lib/android-sdk/tools/proguard/proguard-android.txt
# You can edit the include path and order by changing the proguardFiles
# directive in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# Add any project specific keep options here:
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

View file

@ -0,0 +1,2 @@
<manifest package="com.termux.view">
</manifest>

View file

@ -0,0 +1,112 @@
package com.termux.view;
import android.content.Context;
import android.view.GestureDetector;
import android.view.MotionEvent;
import android.view.ScaleGestureDetector;
/** A combination of {@link GestureDetector} and {@link ScaleGestureDetector}. */
final class GestureAndScaleRecognizer {
public interface Listener {
boolean onSingleTapUp(MotionEvent e);
boolean onDoubleTap(MotionEvent e);
boolean onScroll(MotionEvent e2, float dx, float dy);
boolean onFling(MotionEvent e, float velocityX, float velocityY);
boolean onScale(float focusX, float focusY, float scale);
boolean onDown(float x, float y);
boolean onUp(MotionEvent e);
void onLongPress(MotionEvent e);
}
private final GestureDetector mGestureDetector;
private final ScaleGestureDetector mScaleDetector;
final Listener mListener;
boolean isAfterLongPress;
public GestureAndScaleRecognizer(Context context, Listener listener) {
mListener = listener;
mGestureDetector = new GestureDetector(context, new GestureDetector.SimpleOnGestureListener() {
@Override
public boolean onScroll(MotionEvent e1, MotionEvent e2, float dx, float dy) {
return mListener.onScroll(e2, dx, dy);
}
@Override
public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
return mListener.onFling(e2, velocityX, velocityY);
}
@Override
public boolean onDown(MotionEvent e) {
return mListener.onDown(e.getX(), e.getY());
}
@Override
public void onLongPress(MotionEvent e) {
mListener.onLongPress(e);
isAfterLongPress = true;
}
}, null, true /* ignoreMultitouch */);
mGestureDetector.setOnDoubleTapListener(new GestureDetector.OnDoubleTapListener() {
@Override
public boolean onSingleTapConfirmed(MotionEvent e) {
return mListener.onSingleTapUp(e);
}
@Override
public boolean onDoubleTap(MotionEvent e) {
return mListener.onDoubleTap(e);
}
@Override
public boolean onDoubleTapEvent(MotionEvent e) {
return true;
}
});
mScaleDetector = new ScaleGestureDetector(context, new ScaleGestureDetector.SimpleOnScaleGestureListener() {
@Override
public boolean onScaleBegin(ScaleGestureDetector detector) {
return true;
}
@Override
public boolean onScale(ScaleGestureDetector detector) {
return mListener.onScale(detector.getFocusX(), detector.getFocusY(), detector.getScaleFactor());
}
});
mScaleDetector.setQuickScaleEnabled(false);
}
public void onTouchEvent(MotionEvent event) {
mGestureDetector.onTouchEvent(event);
mScaleDetector.onTouchEvent(event);
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
isAfterLongPress = false;
break;
case MotionEvent.ACTION_UP:
if (!isAfterLongPress) {
// This behaviour is desired when in e.g. vim with mouse events, where we do not
// want to move the cursor when lifting finger after a long press.
mListener.onUp(event);
}
break;
}
}
public boolean isInProgress() {
return mScaleDetector.isInProgress();
}
}

View file

@ -0,0 +1,249 @@
package com.termux.view;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.PorterDuff;
import android.graphics.Typeface;
import com.termux.terminal.TerminalBuffer;
import com.termux.terminal.TerminalEmulator;
import com.termux.terminal.TerminalRow;
import com.termux.terminal.TextStyle;
import com.termux.terminal.WcWidth;
/**
* Renderer of a {@link TerminalEmulator} into a {@link Canvas}.
* <p/>
* Saves font metrics, so needs to be recreated each time the typeface or font size changes.
*/
public final class TerminalRenderer {
final int mTextSize;
final Typeface mTypeface;
private final Paint mTextPaint = new Paint();
/** The width of a single mono spaced character obtained by {@link Paint#measureText(String)} on a single 'X'. */
final float mFontWidth;
/** The {@link Paint#getFontSpacing()}. See http://www.fampennings.nl/maarten/android/08numgrid/font.png */
final int mFontLineSpacing;
/** The {@link Paint#ascent()}. See http://www.fampennings.nl/maarten/android/08numgrid/font.png */
private final int mFontAscent;
/** The {@link #mFontLineSpacing} + {@link #mFontAscent}. */
final int mFontLineSpacingAndAscent;
private final float[] asciiMeasures = new float[127];
public TerminalRenderer(int textSize, Typeface typeface) {
mTextSize = textSize;
mTypeface = typeface;
mTextPaint.setTypeface(typeface);
mTextPaint.setAntiAlias(true);
mTextPaint.setTextSize(textSize);
mFontLineSpacing = (int) Math.ceil(mTextPaint.getFontSpacing());
mFontAscent = (int) Math.ceil(mTextPaint.ascent());
mFontLineSpacingAndAscent = mFontLineSpacing + mFontAscent;
mFontWidth = mTextPaint.measureText("X");
StringBuilder sb = new StringBuilder(" ");
for (int i = 0; i < asciiMeasures.length; i++) {
sb.setCharAt(0, (char) i);
asciiMeasures[i] = mTextPaint.measureText(sb, 0, 1);
}
}
/** Render the terminal to a canvas with at a specified row scroll, and an optional rectangular selection. */
public final void render(TerminalEmulator mEmulator, Canvas canvas, int topRow,
int selectionY1, int selectionY2, int selectionX1, int selectionX2) {
final boolean reverseVideo = mEmulator.isReverseVideo();
final int endRow = topRow + mEmulator.mRows;
final int columns = mEmulator.mColumns;
final int cursorCol = mEmulator.getCursorCol();
final int cursorRow = mEmulator.getCursorRow();
final boolean cursorVisible = mEmulator.shouldCursorBeVisible();
final TerminalBuffer screen = mEmulator.getScreen();
final int[] palette = mEmulator.mColors.mCurrentColors;
final int cursorShape = mEmulator.getCursorStyle();
if (reverseVideo)
canvas.drawColor(palette[TextStyle.COLOR_INDEX_FOREGROUND], PorterDuff.Mode.SRC);
float heightOffset = mFontLineSpacingAndAscent;
for (int row = topRow; row < endRow; row++) {
heightOffset += mFontLineSpacing;
final int cursorX = (row == cursorRow && cursorVisible) ? cursorCol : -1;
int selx1 = -1, selx2 = -1;
if (row >= selectionY1 && row <= selectionY2) {
if (row == selectionY1) selx1 = selectionX1;
selx2 = (row == selectionY2) ? selectionX2 : mEmulator.mColumns;
}
TerminalRow lineObject = screen.allocateFullLineIfNecessary(screen.externalToInternalRow(row));
final char[] line = lineObject.mText;
final int charsUsedInLine = lineObject.getSpaceUsed();
long lastRunStyle = 0;
boolean lastRunInsideCursor = false;
boolean lastRunInsideSelection = false;
int lastRunStartColumn = -1;
int lastRunStartIndex = 0;
boolean lastRunFontWidthMismatch = false;
int currentCharIndex = 0;
float measuredWidthForRun = 0.f;
for (int column = 0; column < columns; ) {
final char charAtIndex = line[currentCharIndex];
final boolean charIsHighsurrogate = Character.isHighSurrogate(charAtIndex);
final int charsForCodePoint = charIsHighsurrogate ? 2 : 1;
final int codePoint = charIsHighsurrogate ? Character.toCodePoint(charAtIndex, line[currentCharIndex + 1]) : charAtIndex;
final int codePointWcWidth = WcWidth.width(codePoint);
final boolean insideCursor = (cursorX == column || (codePointWcWidth == 2 && cursorX == column + 1));
final boolean insideSelection = column >= selx1 && column <= selx2;
final long style = lineObject.getStyle(column);
// Check if the measured text width for this code point is not the same as that expected by wcwidth().
// This could happen for some fonts which are not truly monospace, or for more exotic characters such as
// smileys which android font renders as wide.
// If this is detected, we draw this code point scaled to match what wcwidth() expects.
final float measuredCodePointWidth = (codePoint < asciiMeasures.length) ? asciiMeasures[codePoint] : mTextPaint.measureText(line,
currentCharIndex, charsForCodePoint);
final boolean fontWidthMismatch = Math.abs(measuredCodePointWidth / mFontWidth - codePointWcWidth) > 0.01;
if (style != lastRunStyle || insideCursor != lastRunInsideCursor || insideSelection != lastRunInsideSelection || fontWidthMismatch || lastRunFontWidthMismatch) {
if (column == 0) {
// Skip first column as there is nothing to draw, just record the current style.
} else {
final int columnWidthSinceLastRun = column - lastRunStartColumn;
final int charsSinceLastRun = currentCharIndex - lastRunStartIndex;
int cursorColor = lastRunInsideCursor ? mEmulator.mColors.mCurrentColors[TextStyle.COLOR_INDEX_CURSOR] : 0;
boolean invertCursorTextColor = false;
if (lastRunInsideCursor && cursorShape == TerminalEmulator.TERMINAL_CURSOR_STYLE_BLOCK) {
invertCursorTextColor = true;
}
drawTextRun(canvas, line, palette, heightOffset, lastRunStartColumn, columnWidthSinceLastRun,
lastRunStartIndex, charsSinceLastRun, measuredWidthForRun,
cursorColor, cursorShape, lastRunStyle, reverseVideo || invertCursorTextColor || lastRunInsideSelection);
}
measuredWidthForRun = 0.f;
lastRunStyle = style;
lastRunInsideCursor = insideCursor;
lastRunInsideSelection = insideSelection;
lastRunStartColumn = column;
lastRunStartIndex = currentCharIndex;
lastRunFontWidthMismatch = fontWidthMismatch;
}
measuredWidthForRun += measuredCodePointWidth;
column += codePointWcWidth;
currentCharIndex += charsForCodePoint;
while (currentCharIndex < charsUsedInLine && WcWidth.width(line, currentCharIndex) <= 0) {
// Eat combining chars so that they are treated as part of the last non-combining code point,
// instead of e.g. being considered inside the cursor in the next run.
currentCharIndex += Character.isHighSurrogate(line[currentCharIndex]) ? 2 : 1;
}
}
final int columnWidthSinceLastRun = columns - lastRunStartColumn;
final int charsSinceLastRun = currentCharIndex - lastRunStartIndex;
int cursorColor = lastRunInsideCursor ? mEmulator.mColors.mCurrentColors[TextStyle.COLOR_INDEX_CURSOR] : 0;
boolean invertCursorTextColor = false;
if (lastRunInsideCursor && cursorShape == TerminalEmulator.TERMINAL_CURSOR_STYLE_BLOCK) {
invertCursorTextColor = true;
}
drawTextRun(canvas, line, palette, heightOffset, lastRunStartColumn, columnWidthSinceLastRun, lastRunStartIndex, charsSinceLastRun,
measuredWidthForRun, cursorColor, cursorShape, lastRunStyle, reverseVideo || invertCursorTextColor || lastRunInsideSelection);
}
}
private void drawTextRun(Canvas canvas, char[] text, int[] palette, float y, int startColumn, int runWidthColumns,
int startCharIndex, int runWidthChars, float mes, int cursor, int cursorStyle,
long textStyle, boolean reverseVideo) {
int foreColor = TextStyle.decodeForeColor(textStyle);
final int effect = TextStyle.decodeEffect(textStyle);
int backColor = TextStyle.decodeBackColor(textStyle);
final boolean bold = (effect & (TextStyle.CHARACTER_ATTRIBUTE_BOLD | TextStyle.CHARACTER_ATTRIBUTE_BLINK)) != 0;
final boolean underline = (effect & TextStyle.CHARACTER_ATTRIBUTE_UNDERLINE) != 0;
final boolean italic = (effect & TextStyle.CHARACTER_ATTRIBUTE_ITALIC) != 0;
final boolean strikeThrough = (effect & TextStyle.CHARACTER_ATTRIBUTE_STRIKETHROUGH) != 0;
final boolean dim = (effect & TextStyle.CHARACTER_ATTRIBUTE_DIM) != 0;
if ((foreColor & 0xff000000) != 0xff000000) {
// Let bold have bright colors if applicable (one of the first 8):
if (bold && foreColor >= 0 && foreColor < 8) foreColor += 8;
foreColor = palette[foreColor];
}
if ((backColor & 0xff000000) != 0xff000000) {
backColor = palette[backColor];
}
// Reverse video here if _one and only one_ of the reverse flags are set:
final boolean reverseVideoHere = reverseVideo ^ (effect & (TextStyle.CHARACTER_ATTRIBUTE_INVERSE)) != 0;
if (reverseVideoHere) {
int tmp = foreColor;
foreColor = backColor;
backColor = tmp;
}
float left = startColumn * mFontWidth;
float right = left + runWidthColumns * mFontWidth;
mes = mes / mFontWidth;
boolean savedMatrix = false;
if (Math.abs(mes - runWidthColumns) > 0.01) {
canvas.save();
canvas.scale(runWidthColumns / mes, 1.f);
left *= mes / runWidthColumns;
right *= mes / runWidthColumns;
savedMatrix = true;
}
if (backColor != palette[TextStyle.COLOR_INDEX_BACKGROUND]) {
// Only draw non-default background.
mTextPaint.setColor(backColor);
canvas.drawRect(left, y - mFontLineSpacingAndAscent + mFontAscent, right, y, mTextPaint);
}
if (cursor != 0) {
mTextPaint.setColor(cursor);
float cursorHeight = mFontLineSpacingAndAscent - mFontAscent;
if (cursorStyle == TerminalEmulator.TERMINAL_CURSOR_STYLE_UNDERLINE) cursorHeight /= 4.;
else if (cursorStyle == TerminalEmulator.TERMINAL_CURSOR_STYLE_BAR) right -= ((right - left) * 3) / 4.;
canvas.drawRect(left, y - cursorHeight, right, y, mTextPaint);
}
if ((effect & TextStyle.CHARACTER_ATTRIBUTE_INVISIBLE) == 0) {
if (dim) {
int red = (0xFF & (foreColor >> 16));
int green = (0xFF & (foreColor >> 8));
int blue = (0xFF & foreColor);
// Dim color handling used by libvte which in turn took it from xterm
// (https://bug735245.bugzilla-attachments.gnome.org/attachment.cgi?id=284267):
red = red * 2 / 3;
green = green * 2 / 3;
blue = blue * 2 / 3;
foreColor = 0xFF000000 + (red << 16) + (green << 8) + blue;
}
mTextPaint.setFakeBoldText(bold);
mTextPaint.setUnderlineText(underline);
mTextPaint.setTextSkewX(italic ? -0.35f : 0.f);
mTextPaint.setStrikeThruText(strikeThrough);
mTextPaint.setColor(foreColor);
// The text alignment is the default Paint.Align.LEFT.
canvas.drawTextRun(text, startCharIndex, runWidthChars, startCharIndex, runWidthChars, left, y - mFontLineSpacingAndAscent, false, mTextPaint);
}
if (savedMatrix) canvas.restore();
}
public float getFontWidth() {
return mFontWidth;
}
public int getFontLineSpacing() {
return mFontLineSpacing;
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,83 @@
package com.termux.view;
import android.view.KeyEvent;
import android.view.MotionEvent;
import android.view.ScaleGestureDetector;
import android.view.View;
import com.termux.terminal.TerminalSession;
/**
* The interface for communication between {@link TerminalView} and its client. It allows for getting
* various configuration options from the client and for sending back data to the client like logs,
* key events, both hardware and IME (which makes it different from that available with
* {@link View#setOnKeyListener(View.OnKeyListener)}, etc. It must be set for the
* {@link TerminalView} through {@link TerminalView#setTerminalViewClient(TerminalViewClient)}.
*/
public interface TerminalViewClient {
/**
* Callback function on scale events according to {@link ScaleGestureDetector#getScaleFactor()}.
*/
float onScale(float scale);
/**
* On a single tap on the terminal if terminal mouse reporting not enabled.
*/
void onSingleTapUp(MotionEvent e);
boolean shouldBackButtonBeMappedToEscape();
boolean shouldEnforceCharBasedInput();
boolean shouldUseCtrlSpaceWorkaround();
boolean isTerminalViewSelected();
void copyModeChanged(boolean copyMode);
boolean onKeyDown(int keyCode, KeyEvent e, TerminalSession session);
boolean onKeyUp(int keyCode, KeyEvent e);
boolean onLongPress(MotionEvent event);
boolean readControlKey();
boolean readAltKey();
boolean readShiftKey();
boolean readFnKey();
boolean onCodePoint(int codePoint, boolean ctrlDown, TerminalSession session);
void onEmulatorSet();
void logError(String tag, String message);
void logWarn(String tag, String message);
void logInfo(String tag, String message);
void logDebug(String tag, String message);
void logVerbose(String tag, String message);
void logStackTraceWithMessage(String tag, String message, Exception e);
void logStackTrace(String tag, Exception e);
}

View file

@ -0,0 +1,55 @@
package com.termux.view.textselection;
import android.view.MotionEvent;
import android.view.ViewTreeObserver;
import com.termux.view.TerminalView;
/**
* A CursorController instance can be used to control cursors in the text.
* It is not used outside of {@link TerminalView}.
*/
public interface CursorController extends ViewTreeObserver.OnTouchModeChangeListener {
/**
* Show the cursors on screen. Will be drawn by {@link #render()} by a call during onDraw.
* See also {@link #hide()}.
*/
void show(MotionEvent event);
/**
* Hide the cursors from screen.
* See also {@link #show(MotionEvent event)}.
*/
boolean hide();
/**
* Render the cursors.
*/
void render();
/**
* Update the cursor positions.
*/
void updatePosition(TextSelectionHandleView handle, int x, int y);
/**
* This method is called by {@link #onTouchEvent(MotionEvent)} and gives the cursors
* a chance to become active and/or visible.
*
* @param event The touch event
*/
boolean onTouchEvent(MotionEvent event);
/**
* Called when the view is detached from window. Perform house keeping task, such as
* stopping Runnable thread that would otherwise keep a reference on the context, thus
* preventing the activity to be recycled.
*/
void onDetached();
/**
* @return true if the cursors are currently active.
*/
boolean isActive();
}

View file

@ -0,0 +1,402 @@
package com.termux.view.textselection;
import android.content.ClipData;
import android.content.ClipboardManager;
import android.content.Context;
import android.graphics.Rect;
import android.text.TextUtils;
import android.view.ActionMode;
import android.view.InputDevice;
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;
private 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;
}
public void getSelectors(int[] sel) {
if (sel == null || sel.length != 4) {
return;
}
sel[0] = mSelY1;
sel[1] = mSelY2;
sel[2] = mSelX1;
sel[3] = mSelX2;
}
/** 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();
}
}

View file

@ -0,0 +1,345 @@
package com.termux.view.textselection;
import android.annotation.SuppressLint;
import android.graphics.Canvas;
import android.graphics.Rect;
import android.graphics.drawable.Drawable;
import android.os.SystemClock;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewParent;
import android.view.WindowManager;
import android.widget.PopupWindow;
import com.termux.view.R;
import com.termux.view.TerminalView;
@SuppressLint("ViewConstructor")
public class TextSelectionHandleView extends View {
private final TerminalView terminalView;
private PopupWindow mHandle;
private final CursorController mCursorController;
private final Drawable mHandleLeftDrawable;
private final Drawable mHandleRightDrawable;
private Drawable mHandleDrawable;
private boolean mIsDragging;
final int[] mTempCoords = new int[2];
Rect mTempRect;
private int mPointX;
private int mPointY;
private float mTouchToWindowOffsetX;
private float mTouchToWindowOffsetY;
private float mHotspotX;
private float mHotspotY;
private float mTouchOffsetY;
private int mLastParentX;
private int mLastParentY;
private int mHandleHeight;
private int mHandleWidth;
private final int mInitialOrientation;
private int mOrientation;
public static final int LEFT = 0;
public static final int RIGHT = 2;
private long mLastTime;
public TextSelectionHandleView(TerminalView terminalView, CursorController cursorController, int initialOrientation) {
super(terminalView.getContext());
this.terminalView = terminalView;
mCursorController = cursorController;
mInitialOrientation = initialOrientation;
mHandleLeftDrawable = getContext().getDrawable(R.drawable.text_select_handle_left_material);
mHandleRightDrawable = getContext().getDrawable(R.drawable.text_select_handle_right_material);
setOrientation(mInitialOrientation);
}
private void initHandle() {
mHandle = new PopupWindow(terminalView.getContext(), null,
android.R.attr.textSelectHandleWindowStyle);
mHandle.setSplitTouchEnabled(true);
mHandle.setClippingEnabled(false);
mHandle.setWindowLayoutType(WindowManager.LayoutParams.TYPE_APPLICATION_SUB_PANEL);
mHandle.setWidth(ViewGroup.LayoutParams.WRAP_CONTENT);
mHandle.setHeight(ViewGroup.LayoutParams.WRAP_CONTENT);
mHandle.setBackgroundDrawable(null);
mHandle.setAnimationStyle(0);
mHandle.setEnterTransition(null);
mHandle.setExitTransition(null);
mHandle.setContentView(this);
}
public void setOrientation(int orientation) {
mOrientation = orientation;
int handleWidth = 0;
switch (orientation) {
case LEFT: {
mHandleDrawable = mHandleLeftDrawable;
handleWidth = mHandleDrawable.getIntrinsicWidth();
mHotspotX = (handleWidth * 3) / (float) 4;
break;
}
case RIGHT: {
mHandleDrawable = mHandleRightDrawable;
handleWidth = mHandleDrawable.getIntrinsicWidth();
mHotspotX = handleWidth / (float) 4;
break;
}
}
mHandleHeight = mHandleDrawable.getIntrinsicHeight();
mHandleWidth = handleWidth;
mTouchOffsetY = -mHandleHeight * 0.3f;
mHotspotY = 0;
invalidate();
}
public void show() {
if (!isPositionVisible()) {
hide();
return;
}
// We remove handle from its parent first otherwise the following exception may be thrown
// java.lang.IllegalStateException: The specified child already has a parent. You must call removeView() on the child's parent first.
removeFromParent();
initHandle(); // init the handle
invalidate(); // invalidate to make sure onDraw is called
final int[] coords = mTempCoords;
terminalView.getLocationInWindow(coords);
coords[0] += mPointX;
coords[1] += mPointY;
if (mHandle != null)
mHandle.showAtLocation(terminalView, 0, coords[0], coords[1]);
}
public void hide() {
mIsDragging = false;
if (mHandle != null) {
mHandle.dismiss();
// We remove handle from its parent, otherwise it may still be shown in some cases even after the dismiss call
removeFromParent();
mHandle = null; // garbage collect the handle
}
invalidate();
}
public void removeFromParent() {
if (!isParentNull()) {
((ViewGroup)this.getParent()).removeView(this);
}
}
public void positionAtCursor(final int cx, final int cy, boolean forceOrientationCheck) {
int x = terminalView.getPointX(cx);
int y = terminalView.getPointY(cy + 1);
moveTo(x, y, forceOrientationCheck);
}
private void moveTo(int x, int y, boolean forceOrientationCheck) {
float oldHotspotX = mHotspotX;
checkChangedOrientation(x, forceOrientationCheck);
mPointX = (int) (x - (isShowing() ? oldHotspotX : mHotspotX));
mPointY = y;
if (isPositionVisible()) {
int[] coords = null;
if (isShowing()) {
coords = mTempCoords;
terminalView.getLocationInWindow(coords);
int x1 = coords[0] + mPointX;
int y1 = coords[1] + mPointY;
if (mHandle != null)
mHandle.update(x1, y1, getWidth(), getHeight());
} else {
show();
}
if (mIsDragging) {
if (coords == null) {
coords = mTempCoords;
terminalView.getLocationInWindow(coords);
}
if (coords[0] != mLastParentX || coords[1] != mLastParentY) {
mTouchToWindowOffsetX += coords[0] - mLastParentX;
mTouchToWindowOffsetY += coords[1] - mLastParentY;
mLastParentX = coords[0];
mLastParentY = coords[1];
}
}
} else {
hide();
}
}
public void changeOrientation(int orientation) {
if (mOrientation != orientation) {
setOrientation(orientation);
}
}
private void checkChangedOrientation(int posX, boolean force) {
if (!mIsDragging && !force) {
return;
}
long millis = SystemClock.currentThreadTimeMillis();
if (millis - mLastTime < 50 && !force) {
return;
}
mLastTime = millis;
final TerminalView hostView = terminalView;
final int left = hostView.getLeft();
final int right = hostView.getWidth();
final int top = hostView.getTop();
final int bottom = hostView.getHeight();
if (mTempRect == null) {
mTempRect = new Rect();
}
final Rect clip = mTempRect;
clip.left = left + terminalView.getPaddingLeft();
clip.top = top + terminalView.getPaddingTop();
clip.right = right - terminalView.getPaddingRight();
clip.bottom = bottom - terminalView.getPaddingBottom();
final ViewParent parent = hostView.getParent();
if (parent == null || !parent.getChildVisibleRect(hostView, clip, null)) {
return;
}
if (posX - mHandleWidth < clip.left) {
changeOrientation(RIGHT);
} else if (posX + mHandleWidth > clip.right) {
changeOrientation(LEFT);
} else {
changeOrientation(mInitialOrientation);
}
}
private boolean isPositionVisible() {
// Always show a dragging handle.
if (mIsDragging) {
return true;
}
final TerminalView hostView = terminalView;
final int left = 0;
final int right = hostView.getWidth();
final int top = 0;
final int bottom = hostView.getHeight();
if (mTempRect == null) {
mTempRect = new Rect();
}
final Rect clip = mTempRect;
clip.left = left + terminalView.getPaddingLeft();
clip.top = top + terminalView.getPaddingTop();
clip.right = right - terminalView.getPaddingRight();
clip.bottom = bottom - terminalView.getPaddingBottom();
final ViewParent parent = hostView.getParent();
if (parent == null || !parent.getChildVisibleRect(hostView, clip, null)) {
return false;
}
final int[] coords = mTempCoords;
hostView.getLocationInWindow(coords);
final int posX = coords[0] + mPointX + (int) mHotspotX;
final int posY = coords[1] + mPointY + (int) mHotspotY;
return posX >= clip.left && posX <= clip.right &&
posY >= clip.top && posY <= clip.bottom;
}
@Override
public void onDraw(Canvas c) {
final int width = mHandleDrawable.getIntrinsicWidth();
int height = mHandleDrawable.getIntrinsicHeight();
mHandleDrawable.setBounds(0, 0, width, height);
mHandleDrawable.draw(c);
}
@SuppressLint("ClickableViewAccessibility")
@Override
public boolean onTouchEvent(MotionEvent event) {
terminalView.updateFloatingToolbarVisibility(event);
switch (event.getActionMasked()) {
case MotionEvent.ACTION_DOWN: {
final float rawX = event.getRawX();
final float rawY = event.getRawY();
mTouchToWindowOffsetX = rawX - mPointX;
mTouchToWindowOffsetY = rawY - mPointY;
final int[] coords = mTempCoords;
terminalView.getLocationInWindow(coords);
mLastParentX = coords[0];
mLastParentY = coords[1];
mIsDragging = true;
break;
}
case MotionEvent.ACTION_MOVE: {
final float rawX = event.getRawX();
final float rawY = event.getRawY();
final float newPosX = rawX - mTouchToWindowOffsetX + mHotspotX;
final float newPosY = rawY - mTouchToWindowOffsetY + mHotspotY + mTouchOffsetY;
mCursorController.updatePosition(this, Math.round(newPosX), Math.round(newPosY));
break;
}
case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_CANCEL:
mIsDragging = false;
}
return true;
}
@Override
public void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
setMeasuredDimension(mHandleDrawable.getIntrinsicWidth(),
mHandleDrawable.getIntrinsicHeight());
}
public int getHandleHeight() {
return mHandleHeight;
}
public int getHandleWidth() {
return mHandleWidth;
}
public boolean isShowing() {
if (mHandle != null)
return mHandle.isShowing();
else
return false;
}
public boolean isParentNull() {
return this.getParent() == null;
}
public boolean isDragging() {
return mIsDragging;
}
}

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp"
android:height="24dp"
android:viewportWidth="132"
android:viewportHeight="66">
<path
android:pathData="M52.3,1.6c-5.7,2.1 -12.9,8.6 -16,14.8 -2.2,4.1 -2.8,6.9 -3.1,14.3 -0.6,12.6 1.3,17.8 9.3,25.8 8,8 13.2,9.9 25.8,9.3 11.1,-0.5 17.3,-3.2 23.5,-10.3 6.5,-7.4 7.2,-10.8 7.2,-34.7l0,-20.8 -21.2,0.1c-16.1,-0 -22.3,0.4 -25.5,1.5z"
android:fillColor="#2196F3"
android:strokeColor="#00000000"/>
</vector>

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp"
android:height="24dp"
android:viewportWidth="132"
android:viewportHeight="66">
<path
android:pathData="M33,20.8c0,23.9 0.7,27.3 7.2,34.7 6.2,7.1 12.4,9.8 23.5,10.3 12.6,0.6 17.8,-1.3 25.8,-9.3 8,-8 9.9,-13.2 9.3,-25.8 -0.5,-11.1 -3.2,-17.3 -10.3,-23.5 -7.4,-6.5 -10.8,-7.2 -34.7,-7.2l-20.8,-0 0,20.8z"
android:fillColor="#2196F3"
android:strokeColor="#00000000"/>
</vector>

View file

@ -0,0 +1,5 @@
<resources>
<string name="paste_text">Paste</string>
<string name="copy_text">Copy</string>
<string name="text_selection_more">More…</string>
</resources>