mirror of
https://github.com/fankes/termux-app.git
synced 2025-12-14 21:51:06 +08:00
Some terminal applications, like mutt and weechat, prints a newline at the end of each line even if text is wrapped. This causes urls which are wrapped to not be selectable in full. By ignoring newlines when the text fills the entire width of the screen, those urls can be selected. Many other terminal emulators do this as well. A drawback of this is that if a url happens to fill the width of the screen, the url selection will include the first word of the next line, but this doesn't happen that often so I think it's an okay tradeoff. Fixes #313
456 lines
22 KiB
Java
456 lines
22 KiB
Java
package com.termux.terminal;
|
|
|
|
import java.util.Arrays;
|
|
|
|
/**
|
|
* A circular buffer of {@link TerminalRow}:s which keeps notes about what is visible on a logical screen and the scroll
|
|
* history.
|
|
* <p>
|
|
* See {@link #externalToInternalRow(int)} for how to map from logical screen rows to array indices.
|
|
*/
|
|
public final class TerminalBuffer {
|
|
|
|
TerminalRow[] mLines;
|
|
/** The length of {@link #mLines}. */
|
|
int mTotalRows;
|
|
/** The number of rows and columns visible on the screen. */
|
|
int mScreenRows, mColumns;
|
|
/** The number of rows kept in history. */
|
|
private int mActiveTranscriptRows = 0;
|
|
/** The index in the circular buffer where the visible screen starts. */
|
|
private int mScreenFirstRow = 0;
|
|
|
|
/**
|
|
* Create a transcript screen.
|
|
*
|
|
* @param columns the width of the screen in characters.
|
|
* @param totalRows the height of the entire text area, in rows of text.
|
|
* @param screenRows the height of just the screen, not including the transcript that holds lines that have scrolled off
|
|
* the top of the screen.
|
|
*/
|
|
public TerminalBuffer(int columns, int totalRows, int screenRows) {
|
|
mColumns = columns;
|
|
mTotalRows = totalRows;
|
|
mScreenRows = screenRows;
|
|
mLines = new TerminalRow[totalRows];
|
|
|
|
blockSet(0, 0, columns, screenRows, ' ', TextStyle.NORMAL);
|
|
}
|
|
|
|
public String getTranscriptText() {
|
|
return getSelectedText(0, -getActiveTranscriptRows(), mColumns, mScreenRows).trim();
|
|
}
|
|
|
|
public String getTranscriptTextWithoutJoinedLines() {
|
|
return getSelectedText(0, -getActiveTranscriptRows(), mColumns, mScreenRows, false).trim();
|
|
}
|
|
|
|
public String getTranscriptTextWithFullLinesJoined() {
|
|
return getSelectedText(0, -getActiveTranscriptRows(), mColumns, mScreenRows, true, true).trim();
|
|
}
|
|
|
|
public String getSelectedText(int selX1, int selY1, int selX2, int selY2) {
|
|
return getSelectedText(selX1, selY1, selX2, selY2, true);
|
|
}
|
|
|
|
public String getSelectedText(int selX1, int selY1, int selX2, int selY2, boolean joinBackLines) {
|
|
return getSelectedText(selX1, selY1, selX2, selY2, true, false);
|
|
}
|
|
|
|
public String getSelectedText(int selX1, int selY1, int selX2, int selY2, boolean joinBackLines, boolean joinFullLines) {
|
|
final StringBuilder builder = new StringBuilder();
|
|
final int columns = mColumns;
|
|
|
|
if (selY1 < -getActiveTranscriptRows()) selY1 = -getActiveTranscriptRows();
|
|
if (selY2 >= mScreenRows) selY2 = mScreenRows - 1;
|
|
|
|
for (int row = selY1; row <= selY2; row++) {
|
|
int x1 = (row == selY1) ? selX1 : 0;
|
|
int x2;
|
|
if (row == selY2) {
|
|
x2 = selX2 + 1;
|
|
if (x2 > columns) x2 = columns;
|
|
} else {
|
|
x2 = columns;
|
|
}
|
|
TerminalRow lineObject = mLines[externalToInternalRow(row)];
|
|
int x1Index = lineObject.findStartOfColumn(x1);
|
|
int x2Index = (x2 < mColumns) ? lineObject.findStartOfColumn(x2) : lineObject.getSpaceUsed();
|
|
if (x2Index == x1Index) {
|
|
// Selected the start of a wide character.
|
|
x2Index = lineObject.findStartOfColumn(x2 + 1);
|
|
}
|
|
char[] line = lineObject.mText;
|
|
int lastPrintingCharIndex = -1;
|
|
int i;
|
|
boolean rowLineWrap = getLineWrap(row);
|
|
if (rowLineWrap && x2 == columns) {
|
|
// If the line was wrapped, we shouldn't lose trailing space:
|
|
lastPrintingCharIndex = x2Index - 1;
|
|
} else {
|
|
for (i = x1Index; i < x2Index; ++i) {
|
|
char c = line[i];
|
|
if (c != ' ') lastPrintingCharIndex = i;
|
|
}
|
|
}
|
|
if (lastPrintingCharIndex != -1)
|
|
builder.append(line, x1Index, lastPrintingCharIndex - x1Index + 1);
|
|
boolean lineFillsWidth = lastPrintingCharIndex == x2Index - 1;
|
|
if ((!joinBackLines || !rowLineWrap) && (!joinFullLines || !lineFillsWidth)
|
|
&& row < selY2 && row < mScreenRows - 1) builder.append('\n');
|
|
}
|
|
return builder.toString();
|
|
}
|
|
|
|
public int getActiveTranscriptRows() {
|
|
return mActiveTranscriptRows;
|
|
}
|
|
|
|
public int getActiveRows() {
|
|
return mActiveTranscriptRows + mScreenRows;
|
|
}
|
|
|
|
/**
|
|
* Convert a row value from the public external coordinate system to our internal private coordinate system.
|
|
*
|
|
* <pre>
|
|
* - External coordinate system: -mActiveTranscriptRows to mScreenRows-1, with the screen being 0..mScreenRows-1.
|
|
* - Internal coordinate system: the mScreenRows lines starting at mScreenFirstRow comprise the screen, while the
|
|
* mActiveTranscriptRows lines ending at mScreenFirstRow-1 form the transcript (as a circular buffer).
|
|
*
|
|
* External ↔ Internal:
|
|
*
|
|
* [ ... ] [ ... ]
|
|
* [ -mActiveTranscriptRows ] [ mScreenFirstRow - mActiveTranscriptRows ]
|
|
* [ ... ] [ ... ]
|
|
* [ 0 (visible screen starts here) ] ↔ [ mScreenFirstRow ]
|
|
* [ ... ] [ ... ]
|
|
* [ mScreenRows-1 ] [ mScreenFirstRow + mScreenRows-1 ]
|
|
* </pre>
|
|
*
|
|
* @param externalRow a row in the external coordinate system.
|
|
* @return The row corresponding to the input argument in the private coordinate system.
|
|
*/
|
|
public int externalToInternalRow(int externalRow) {
|
|
if (externalRow < -mActiveTranscriptRows || externalRow > mScreenRows)
|
|
throw new IllegalArgumentException("extRow=" + externalRow + ", mScreenRows=" + mScreenRows + ", mActiveTranscriptRows=" + mActiveTranscriptRows);
|
|
final int internalRow = mScreenFirstRow + externalRow;
|
|
return (internalRow < 0) ? (mTotalRows + internalRow) : (internalRow % mTotalRows);
|
|
}
|
|
|
|
public void setLineWrap(int row) {
|
|
mLines[externalToInternalRow(row)].mLineWrap = true;
|
|
}
|
|
|
|
public boolean getLineWrap(int row) {
|
|
return mLines[externalToInternalRow(row)].mLineWrap;
|
|
}
|
|
|
|
public void clearLineWrap(int row) {
|
|
mLines[externalToInternalRow(row)].mLineWrap = false;
|
|
}
|
|
|
|
/**
|
|
* Resize the screen which this transcript backs. Currently, this only works if the number of columns does not
|
|
* change or the rows expand (that is, it only works when shrinking the number of rows).
|
|
*
|
|
* @param newColumns The number of columns the screen should have.
|
|
* @param newRows The number of rows the screen should have.
|
|
* @param cursor An int[2] containing the (column, row) cursor location.
|
|
*/
|
|
public void resize(int newColumns, int newRows, int newTotalRows, int[] cursor, long currentStyle, boolean altScreen) {
|
|
// newRows > mTotalRows should not normally happen since mTotalRows is TRANSCRIPT_ROWS (10000):
|
|
if (newColumns == mColumns && newRows <= mTotalRows) {
|
|
// Fast resize where just the rows changed.
|
|
int shiftDownOfTopRow = mScreenRows - newRows;
|
|
if (shiftDownOfTopRow > 0 && shiftDownOfTopRow < mScreenRows) {
|
|
// Shrinking. Check if we can skip blank rows at bottom below cursor.
|
|
for (int i = mScreenRows - 1; i > 0; i--) {
|
|
if (cursor[1] >= i) break;
|
|
int r = externalToInternalRow(i);
|
|
if (mLines[r] == null || mLines[r].isBlank()) {
|
|
if (--shiftDownOfTopRow == 0) break;
|
|
}
|
|
}
|
|
} else if (shiftDownOfTopRow < 0) {
|
|
// Negative shift down = expanding. Only move screen up if there is transcript to show:
|
|
int actualShift = Math.max(shiftDownOfTopRow, -mActiveTranscriptRows);
|
|
if (shiftDownOfTopRow != actualShift) {
|
|
// The new lines revealed by the resizing are not all from the transcript. Blank the below ones.
|
|
for (int i = 0; i < actualShift - shiftDownOfTopRow; i++)
|
|
allocateFullLineIfNecessary((mScreenFirstRow + mScreenRows + i) % mTotalRows).clear(currentStyle);
|
|
shiftDownOfTopRow = actualShift;
|
|
}
|
|
}
|
|
mScreenFirstRow += shiftDownOfTopRow;
|
|
mScreenFirstRow = (mScreenFirstRow < 0) ? (mScreenFirstRow + mTotalRows) : (mScreenFirstRow % mTotalRows);
|
|
mTotalRows = newTotalRows;
|
|
mActiveTranscriptRows = altScreen ? 0 : Math.max(0, mActiveTranscriptRows + shiftDownOfTopRow);
|
|
cursor[1] -= shiftDownOfTopRow;
|
|
mScreenRows = newRows;
|
|
} else {
|
|
// Copy away old state and update new:
|
|
TerminalRow[] oldLines = mLines;
|
|
mLines = new TerminalRow[newTotalRows];
|
|
for (int i = 0; i < newTotalRows; i++)
|
|
mLines[i] = new TerminalRow(newColumns, currentStyle);
|
|
|
|
final int oldActiveTranscriptRows = mActiveTranscriptRows;
|
|
final int oldScreenFirstRow = mScreenFirstRow;
|
|
final int oldScreenRows = mScreenRows;
|
|
final int oldTotalRows = mTotalRows;
|
|
mTotalRows = newTotalRows;
|
|
mScreenRows = newRows;
|
|
mActiveTranscriptRows = mScreenFirstRow = 0;
|
|
mColumns = newColumns;
|
|
|
|
int newCursorRow = -1;
|
|
int newCursorColumn = -1;
|
|
int oldCursorRow = cursor[1];
|
|
int oldCursorColumn = cursor[0];
|
|
boolean newCursorPlaced = false;
|
|
|
|
int currentOutputExternalRow = 0;
|
|
int currentOutputExternalColumn = 0;
|
|
|
|
// Loop over every character in the initial state.
|
|
// Blank lines should be skipped only if at end of transcript (just as is done in the "fast" resize), so we
|
|
// keep track how many blank lines we have skipped if we later on find a non-blank line.
|
|
int skippedBlankLines = 0;
|
|
for (int externalOldRow = -oldActiveTranscriptRows; externalOldRow < oldScreenRows; externalOldRow++) {
|
|
// Do what externalToInternalRow() does but for the old state:
|
|
int internalOldRow = oldScreenFirstRow + externalOldRow;
|
|
internalOldRow = (internalOldRow < 0) ? (oldTotalRows + internalOldRow) : (internalOldRow % oldTotalRows);
|
|
|
|
TerminalRow oldLine = oldLines[internalOldRow];
|
|
boolean cursorAtThisRow = externalOldRow == oldCursorRow;
|
|
// The cursor may only be on a non-null line, which we should not skip:
|
|
if (oldLine == null || (!(!newCursorPlaced && cursorAtThisRow)) && oldLine.isBlank()) {
|
|
skippedBlankLines++;
|
|
continue;
|
|
} else if (skippedBlankLines > 0) {
|
|
// After skipping some blank lines we encounter a non-blank line. Insert the skipped blank lines.
|
|
for (int i = 0; i < skippedBlankLines; i++) {
|
|
if (currentOutputExternalRow == mScreenRows - 1) {
|
|
scrollDownOneLine(0, mScreenRows, currentStyle);
|
|
} else {
|
|
currentOutputExternalRow++;
|
|
}
|
|
currentOutputExternalColumn = 0;
|
|
}
|
|
skippedBlankLines = 0;
|
|
}
|
|
|
|
int lastNonSpaceIndex = 0;
|
|
boolean justToCursor = false;
|
|
if (cursorAtThisRow || oldLine.mLineWrap) {
|
|
// Take the whole line, either because of cursor on it, or if line wrapping.
|
|
lastNonSpaceIndex = oldLine.getSpaceUsed();
|
|
if (cursorAtThisRow) justToCursor = true;
|
|
} else {
|
|
for (int i = 0; i < oldLine.getSpaceUsed(); i++)
|
|
// NEWLY INTRODUCED BUG! Should not index oldLine.mStyle with char indices
|
|
if (oldLine.mText[i] != ' '/* || oldLine.mStyle[i] != currentStyle */)
|
|
lastNonSpaceIndex = i + 1;
|
|
}
|
|
|
|
int currentOldCol = 0;
|
|
long styleAtCol = 0;
|
|
for (int i = 0; i < lastNonSpaceIndex; i++) {
|
|
// Note that looping over java character, not cells.
|
|
char c = oldLine.mText[i];
|
|
int codePoint = (Character.isHighSurrogate(c)) ? Character.toCodePoint(c, oldLine.mText[++i]) : c;
|
|
int displayWidth = WcWidth.width(codePoint);
|
|
// Use the last style if this is a zero-width character:
|
|
if (displayWidth > 0) styleAtCol = oldLine.getStyle(currentOldCol);
|
|
|
|
// Line wrap as necessary:
|
|
if (currentOutputExternalColumn + displayWidth > mColumns) {
|
|
setLineWrap(currentOutputExternalRow);
|
|
if (currentOutputExternalRow == mScreenRows - 1) {
|
|
if (newCursorPlaced) newCursorRow--;
|
|
scrollDownOneLine(0, mScreenRows, currentStyle);
|
|
} else {
|
|
currentOutputExternalRow++;
|
|
}
|
|
currentOutputExternalColumn = 0;
|
|
}
|
|
|
|
int offsetDueToCombiningChar = ((displayWidth <= 0 && currentOutputExternalColumn > 0) ? 1 : 0);
|
|
int outputColumn = currentOutputExternalColumn - offsetDueToCombiningChar;
|
|
setChar(outputColumn, currentOutputExternalRow, codePoint, styleAtCol);
|
|
|
|
if (displayWidth > 0) {
|
|
if (oldCursorRow == externalOldRow && oldCursorColumn == currentOldCol) {
|
|
newCursorColumn = currentOutputExternalColumn;
|
|
newCursorRow = currentOutputExternalRow;
|
|
newCursorPlaced = true;
|
|
}
|
|
currentOldCol += displayWidth;
|
|
currentOutputExternalColumn += displayWidth;
|
|
if (justToCursor && newCursorPlaced) break;
|
|
}
|
|
}
|
|
// Old row has been copied. Check if we need to insert newline if old line was not wrapping:
|
|
if (externalOldRow != (oldScreenRows - 1) && !oldLine.mLineWrap) {
|
|
if (currentOutputExternalRow == mScreenRows - 1) {
|
|
if (newCursorPlaced) newCursorRow--;
|
|
scrollDownOneLine(0, mScreenRows, currentStyle);
|
|
} else {
|
|
currentOutputExternalRow++;
|
|
}
|
|
currentOutputExternalColumn = 0;
|
|
}
|
|
}
|
|
|
|
cursor[0] = newCursorColumn;
|
|
cursor[1] = newCursorRow;
|
|
}
|
|
|
|
// Handle cursor scrolling off screen:
|
|
if (cursor[0] < 0 || cursor[1] < 0) cursor[0] = cursor[1] = 0;
|
|
}
|
|
|
|
/**
|
|
* Block copy lines and associated metadata from one location to another in the circular buffer, taking wraparound
|
|
* into account.
|
|
*
|
|
* @param srcInternal The first line to be copied.
|
|
* @param len The number of lines to be copied.
|
|
*/
|
|
private void blockCopyLinesDown(int srcInternal, int len) {
|
|
if (len == 0) return;
|
|
int totalRows = mTotalRows;
|
|
|
|
int start = len - 1;
|
|
// Save away line to be overwritten:
|
|
TerminalRow lineToBeOverWritten = mLines[(srcInternal + start + 1) % totalRows];
|
|
// Do the copy from bottom to top.
|
|
for (int i = start; i >= 0; --i)
|
|
mLines[(srcInternal + i + 1) % totalRows] = mLines[(srcInternal + i) % totalRows];
|
|
// Put back overwritten line, now above the block:
|
|
mLines[(srcInternal) % totalRows] = lineToBeOverWritten;
|
|
}
|
|
|
|
/**
|
|
* Scroll the screen down one line. To scroll the whole screen of a 24 line screen, the arguments would be (0, 24).
|
|
*
|
|
* @param topMargin First line that is scrolled.
|
|
* @param bottomMargin One line after the last line that is scrolled.
|
|
* @param style the style for the newly exposed line.
|
|
*/
|
|
public void scrollDownOneLine(int topMargin, int bottomMargin, long style) {
|
|
if (topMargin > bottomMargin - 1 || topMargin < 0 || bottomMargin > mScreenRows)
|
|
throw new IllegalArgumentException("topMargin=" + topMargin + ", bottomMargin=" + bottomMargin + ", mScreenRows=" + mScreenRows);
|
|
|
|
// Copy the fixed topMargin lines one line down so that they remain on screen in same position:
|
|
blockCopyLinesDown(mScreenFirstRow, topMargin);
|
|
// Copy the fixed mScreenRows-bottomMargin lines one line down so that they remain on screen in same
|
|
// position:
|
|
blockCopyLinesDown(externalToInternalRow(bottomMargin), mScreenRows - bottomMargin);
|
|
|
|
// Update the screen location in the ring buffer:
|
|
mScreenFirstRow = (mScreenFirstRow + 1) % mTotalRows;
|
|
// Note that the history has grown if not already full:
|
|
if (mActiveTranscriptRows < mTotalRows - mScreenRows) mActiveTranscriptRows++;
|
|
|
|
// Blank the newly revealed line above the bottom margin:
|
|
int blankRow = externalToInternalRow(bottomMargin - 1);
|
|
if (mLines[blankRow] == null) {
|
|
mLines[blankRow] = new TerminalRow(mColumns, style);
|
|
} else {
|
|
mLines[blankRow].clear(style);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Block copy characters from one position in the screen to another. The two positions can overlap. All characters
|
|
* of the source and destination must be within the bounds of the screen, or else an InvalidParameterException will
|
|
* be thrown.
|
|
*
|
|
* @param sx source X coordinate
|
|
* @param sy source Y coordinate
|
|
* @param w width
|
|
* @param h height
|
|
* @param dx destination X coordinate
|
|
* @param dy destination Y coordinate
|
|
*/
|
|
public void blockCopy(int sx, int sy, int w, int h, int dx, int dy) {
|
|
if (w == 0) return;
|
|
if (sx < 0 || sx + w > mColumns || sy < 0 || sy + h > mScreenRows || dx < 0 || dx + w > mColumns || dy < 0 || dy + h > mScreenRows)
|
|
throw new IllegalArgumentException();
|
|
boolean copyingUp = sy > dy;
|
|
for (int y = 0; y < h; y++) {
|
|
int y2 = copyingUp ? y : (h - (y + 1));
|
|
TerminalRow sourceRow = allocateFullLineIfNecessary(externalToInternalRow(sy + y2));
|
|
allocateFullLineIfNecessary(externalToInternalRow(dy + y2)).copyInterval(sourceRow, sx, sx + w, dx);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Block set characters. All characters must be within the bounds of the screen, or else and
|
|
* InvalidParemeterException will be thrown. Typically this is called with a "val" argument of 32 to clear a block
|
|
* of characters.
|
|
*/
|
|
public void blockSet(int sx, int sy, int w, int h, int val, long style) {
|
|
if (sx < 0 || sx + w > mColumns || sy < 0 || sy + h > mScreenRows) {
|
|
throw new IllegalArgumentException(
|
|
"Illegal arguments! blockSet(" + sx + ", " + sy + ", " + w + ", " + h + ", " + val + ", " + mColumns + ", " + mScreenRows + ")");
|
|
}
|
|
for (int y = 0; y < h; y++)
|
|
for (int x = 0; x < w; x++)
|
|
setChar(sx + x, sy + y, val, style);
|
|
}
|
|
|
|
public TerminalRow allocateFullLineIfNecessary(int row) {
|
|
return (mLines[row] == null) ? (mLines[row] = new TerminalRow(mColumns, 0)) : mLines[row];
|
|
}
|
|
|
|
public void setChar(int column, int row, int codePoint, long style) {
|
|
if (row >= mScreenRows || column >= mColumns)
|
|
throw new IllegalArgumentException("row=" + row + ", column=" + column + ", mScreenRows=" + mScreenRows + ", mColumns=" + mColumns);
|
|
row = externalToInternalRow(row);
|
|
allocateFullLineIfNecessary(row).setChar(column, codePoint, style);
|
|
}
|
|
|
|
public long getStyleAt(int externalRow, int column) {
|
|
return allocateFullLineIfNecessary(externalToInternalRow(externalRow)).getStyle(column);
|
|
}
|
|
|
|
/** Support for http://vt100.net/docs/vt510-rm/DECCARA and http://vt100.net/docs/vt510-rm/DECCARA */
|
|
public void setOrClearEffect(int bits, boolean setOrClear, boolean reverse, boolean rectangular, int leftMargin, int rightMargin, int top, int left,
|
|
int bottom, int right) {
|
|
for (int y = top; y < bottom; y++) {
|
|
TerminalRow line = mLines[externalToInternalRow(y)];
|
|
int startOfLine = (rectangular || y == top) ? left : leftMargin;
|
|
int endOfLine = (rectangular || y + 1 == bottom) ? right : rightMargin;
|
|
for (int x = startOfLine; x < endOfLine; x++) {
|
|
long currentStyle = line.getStyle(x);
|
|
int foreColor = TextStyle.decodeForeColor(currentStyle);
|
|
int backColor = TextStyle.decodeBackColor(currentStyle);
|
|
int effect = TextStyle.decodeEffect(currentStyle);
|
|
if (reverse) {
|
|
// Clear out the bits to reverse and add them back in reversed:
|
|
effect = (effect & ~bits) | (bits & ~effect);
|
|
} else if (setOrClear) {
|
|
effect |= bits;
|
|
} else {
|
|
effect &= ~bits;
|
|
}
|
|
line.mStyle[x] = TextStyle.encode(foreColor, backColor, effect);
|
|
}
|
|
}
|
|
}
|
|
|
|
public void clearTranscript() {
|
|
if (mScreenFirstRow < mActiveTranscriptRows) {
|
|
Arrays.fill(mLines, mTotalRows + mScreenFirstRow - mActiveTranscriptRows, mTotalRows, null);
|
|
Arrays.fill(mLines, 0, mScreenFirstRow, null);
|
|
} else {
|
|
Arrays.fill(mLines, mScreenFirstRow - mActiveTranscriptRows, mScreenFirstRow, null);
|
|
}
|
|
mActiveTranscriptRows = 0;
|
|
}
|
|
|
|
}
|