diff --git a/terminal/bundles/org.eclipse.terminal.control/src/org/eclipse/terminal/internal/emulator/VT100EmulatorBackend.java b/terminal/bundles/org.eclipse.terminal.control/src/org/eclipse/terminal/internal/emulator/VT100EmulatorBackend.java
index 43f8b3d490e..bfc6b04cfa2 100644
--- a/terminal/bundles/org.eclipse.terminal.control/src/org/eclipse/terminal/internal/emulator/VT100EmulatorBackend.java
+++ b/terminal/bundles/org.eclipse.terminal.control/src/org/eclipse/terminal/internal/emulator/VT100EmulatorBackend.java
@@ -14,6 +14,10 @@
*******************************************************************************/
package org.eclipse.terminal.internal.emulator;
+import java.text.BreakIterator;
+import java.text.Normalizer;
+
+import org.eclipse.terminal.internal.model.Cells;
import org.eclipse.terminal.internal.model.CharWidth;
import org.eclipse.terminal.internal.model.TerminalTextDataStore;
import org.eclipse.terminal.model.ITerminalTextData;
@@ -89,6 +93,29 @@ int getHeight() {
* and next output requires line wrap */
private boolean fWrapPending;
private boolean fInsertMode;
+
+ /** a run of characters on its way into the cells, with their fillers */
+ private char[] fRun = new char[0];
+
+ /** finds where a grapheme cluster ends, by the rules of UAX #29 */
+ private final BreakIterator fGraphemes = BreakIterator.getCharacterInstance();
+
+ /** the first half of a surrogate pair that ended the last write, waiting for the second */
+ private char fHighSurrogate;
+
+ /** the cluster written last, which what comes next may still go onto */
+ private String fLastText;
+ /** its first cell, and how many lines above the cursor it is: 1 after an immediate wrap */
+ private int fLastColumn, fLastLinesBack;
+ /**
+ * How many times anything but writing text has been done (a cursor move, an
+ * erase, an edit, a scroll), and how many times when the cluster was written:
+ * only what comes straight after it in the text can go on with it.
+ */
+ private int fMoves, fAfterMoves;
+ /** whether a wrap was pending right after it */
+ private boolean fAfterPending;
+
private TerminalStyle fDefaultStyle;
private TerminalStyle fStyle;
int fLines;
@@ -119,6 +146,7 @@ public VT100EmulatorBackend(ITerminalTextData terminal) {
@Override
public void enableAlternateScreen(boolean enable, boolean cursor) {
+ endCluster();
synchronized (fTerminal) {
if (enable == (fNormalScreen != null)) {
// Already on the screen being asked for. Programs do ask twice.
@@ -237,6 +265,7 @@ public void setBufferLineLimit(int bufferLineLimit) {
@Override
public void clearAll() {
+ endCluster();
synchronized (fTerminal) {
if (fNormalScreen != null) {
// Clearing while a full screen program shows clears what is behind it as
@@ -271,6 +300,7 @@ public void setDimensions(int lines, int cols) {
if (lines == fLines && cols == fColumns) {
return; // nothing to do
}
+ fLastText = null; // its place is no longer what it was
// relative cursor line
int cl = getCursorLine();
int cc = getCursorColumn();
@@ -325,31 +355,28 @@ public int toAbsoluteLine(int line) {
@Override
public void insertCharacters(int charactersToInsert) {
+ endCluster();
if (charactersToInsert <= 0) {
return; // nothing moves, so no wide character may be broken either
}
synchronized (fTerminal) {
int line = toAbsoluteLine(fCursorLine);
- int n = charactersToInsert;
- // A wide character is not left half on the line: one the insertion splits
- // is blanked whole, and so is one whose second cell it pushes off the end.
- if (isSecondCell(line, fCursorColumn)) {
- eraseCells(line, fCursorColumn - 1, fCursorColumn + 1);
+ int col = fCursorColumn;
+ int n = Math.min(charactersToInsert, fColumns - col);
+ // A character is not left half on the line: one the insertion splits is
+ // erased whole, and so is one whose second cell it pushes off the end.
+ eraseWhole(line, col, col);
+ if (col + n < fColumns) {
+ eraseWhole(line, fColumns - n, fColumns - n);
}
- if (fCursorColumn + n < fColumns && isSecondCell(line, fColumns - n)) {
- eraseCells(line, fColumns - n - 1, fColumns - n);
- }
- for (int col = fColumns - 1; col >= fCursorColumn + n; col--) {
- char c = fTerminal.getChar(line, col - n);
- TerminalStyle style = fTerminal.getStyle(line, col - n);
- fTerminal.setChar(line, col, c, style);
- }
- eraseCells(line, fCursorColumn, Math.min(fCursorColumn + n, fColumns));
+ moveCells(line, col, fColumns - n, n);
+ eraseCells(line, col, col + n);
}
}
@Override
public void eraseToEndOfScreen() {
+ endCluster();
synchronized (fTerminal) {
eraseLineToEnd();
for (int line = toAbsoluteLine(fCursorLine + 1); line < toAbsoluteLine(fLines); line++) {
@@ -361,6 +388,7 @@ public void eraseToEndOfScreen() {
@Override
public void eraseToCursor() {
+ endCluster();
synchronized (fTerminal) {
for (int line = toAbsoluteLine(0); line < toAbsoluteLine(fCursorLine); line++) {
eraseLine(line);
@@ -371,6 +399,7 @@ public void eraseToCursor() {
@Override
public void eraseAll() {
+ endCluster();
synchronized (fTerminal) {
// What is on the screen goes into the history before it is erased, as in
// Windows Terminal and VTE, so that clearing the screen does not destroy
@@ -400,19 +429,12 @@ public void eraseAll() {
}
private boolean isBlank(int line) {
- char[] chars = fTerminal.getChars(line);
- if (chars != null) {
- for (char c : chars) {
- if (c != '\000' && c != ' ') {
- return false;
- }
- }
- }
- return true;
+ return Cells.text(fTerminal, line, 0, fColumns).isBlank();
}
@Override
public void eraseLine() {
+ endCluster();
synchronized (fTerminal) {
eraseLine(toAbsoluteLine(fCursorLine));
}
@@ -428,6 +450,7 @@ private void eraseLine(int line) {
@Override
public void eraseLineToEnd() {
+ endCluster();
synchronized (fTerminal) {
int line = toAbsoluteLine(fCursorLine);
eraseWhole(line, fCursorColumn, fColumns);
@@ -443,11 +466,11 @@ public void eraseLineToEnd() {
* @return where the erase ended, past a wide character it took whole
*/
private int eraseWhole(int line, int from, int to) {
- if (isSecondCell(line, from)) {
- from--;
+ if (from < fColumns) {
+ from = Cells.start(fTerminal, line, from);
}
- if (isSecondCell(line, to)) {
- to++;
+ if (to < fColumns && fTerminal.getCellWidth(line, to) == 0) {
+ to++; // the range ends inside a character: all of it goes
}
eraseCells(line, from, to);
return to;
@@ -485,6 +508,7 @@ private TerminalStyle eraseStyle() {
@Override
public void eraseLineToCursor() {
+ endCluster();
synchronized (fTerminal) {
eraseWhole(toAbsoluteLine(fCursorLine), 0, fCursorColumn + 1);
}
@@ -492,6 +516,7 @@ public void eraseLineToCursor() {
@Override
public void insertLines(int n) {
+ endCluster();
synchronized (fTerminal) {
if (!isCusorInScrollingRegion()) {
return;
@@ -505,25 +530,21 @@ public void insertLines(int n) {
@Override
public void deleteCharacters(int n) {
+ endCluster();
if (n <= 0) {
return; // nothing moves, so no wide character may be broken either
}
synchronized (fTerminal) {
int line = toAbsoluteLine(fCursorLine);
- // deleting either half of a wide character leaves none of it
- int end = Math.min(fCursorColumn + n, fColumns);
- if (isSecondCell(line, fCursorColumn)) {
- eraseCells(line, fCursorColumn - 1, fCursorColumn);
- }
- if (isSecondCell(line, end)) {
- eraseCells(line, end, end + 1);
- }
- for (int col = fCursorColumn + n; col < fColumns; col++) {
- char c = fTerminal.getChar(line, col);
- TerminalStyle style = fTerminal.getStyle(line, col);
- fTerminal.setChar(line, col - n, c, style);
+ int col = fCursorColumn;
+ n = Math.min(n, fColumns - col);
+ // deleting either half of a character leaves none of it
+ eraseWhole(line, col, col);
+ if (col + n < fColumns) {
+ eraseWhole(line, col + n, col + n);
}
- eraseCells(line, Math.max(fCursorColumn, fColumns - n), fColumns);
+ moveCells(line, col + n, fColumns, -n);
+ eraseCells(line, fColumns - n, fColumns);
// what reached the margin moved off it, so the line ends before it (xterm)
fTerminal.clearWrappedLine(line);
}
@@ -531,6 +552,7 @@ public void deleteCharacters(int n) {
@Override
public void deleteLines(int n) {
+ endCluster();
synchronized (fTerminal) {
if (!isCusorInScrollingRegion()) {
return;
@@ -580,126 +602,402 @@ public void setStyle(TerminalStyle style) {
@Override
public void appendString(String buffer) {
synchronized (fTerminal) {
+ // a reader may hand over the two halves of a surrogate pair apart
+ if (fHighSurrogate != 0) {
+ buffer = fHighSurrogate + buffer;
+ fHighSurrogate = 0;
+ }
+ if (!buffer.isEmpty() && Character.isHighSurrogate(buffer.charAt(buffer.length() - 1))) {
+ fHighSurrogate = buffer.charAt(buffer.length() - 1);
+ buffer = buffer.substring(0, buffer.length() - 1);
+ }
+ buffer = withoutHidden(buffer);
char[] chars = buffer.toCharArray();
int line = toAbsoluteLine(fCursorLine);
+ boolean[] starts = clusterStarts(lastCluster() >= 0 ? fLastText : "", buffer); //$NON-NLS-1$
int i = 0;
while (i < chars.length) {
+ int codePoint = Character.codePointAt(chars, i);
+ int charsUsed = Character.charCount(codePoint);
+ // What goes on with the cluster written last goes onto it, even when that is
+ // on the last cell with a wrap pending: it belongs there, not on the next line.
+ if ((!starts[i] || takesNoCell(codePoint)) && lastCluster() >= 0) {
+ line = goOn(Character.toString(codePoint));
+ i += charsUsed;
+ continue;
+ }
if (fWrapPending) {
line = doLineWrap();
}
int room = fColumns - fCursorColumn;
- int col;
- int n = narrowRun(chars, i, room);
+ int col = fCursorColumn;
+ // a run of characters of the BMP that are each a cluster of their own goes
+ // in at once: most text is nothing else
+ if (fRun.length < room) {
+ fRun = new char[room];
+ }
+ int n = 0;
+ int cells = 0;
+ int width = 0;
+ while (i + n < chars.length && starts[i + n] && !Character.isSurrogate(chars[i + n])) {
+ width = CharWidth.of(chars[i + n]);
+ if (width == 0 || cells + width > room) {
+ break;
+ }
+ fRun[cells++] = chars[i + n++];
+ if (width == 2) {
+ fRun[cells++] = '\000'; // the filler of a wide character
+ }
+ }
if (n > 0) {
if (fInsertMode) {
// room on the line the run goes to, which a wrap may have changed
- insertCharacters(n);
+ insertCharacters(cells);
}
- breakWideChar(line, fCursorColumn);
- breakWideChar(line, fCursorColumn + n - 1);
- fTerminal.setChars(line, fCursorColumn, chars, i, n, fStyle);
- col = fCursorColumn + n;
+ breakAround(line, col, col + cells);
+ fTerminal.setChars(line, col, fRun, 0, cells, fStyle);
i += n;
- } else {
- int codePoint = Character.codePointAt(chars, i);
- int charsUsed = Character.charCount(codePoint);
- int width = cellWidth(codePoint);
- if (width == 0) {
- // combining marks and other non-printing code points occupy no cell
- i += charsUsed;
- continue;
- }
- if (width > room) {
- if (fCursorColumn > 0) {
- // a wide character is never split across the right margin,
- // and the cell it leaves unused there is erased
- eraseCharacters(room);
- line = doLineWrap();
- continue;
- }
- // terminal narrower than the character itself
- width = room;
- }
- if (fInsertMode) {
- insertCharacters(width); // room in cells, not characters
- }
- breakWideChar(line, fCursorColumn);
- breakWideChar(line, fCursorColumn + width - 1);
- if (charsUsed > width) {
- // a one column terminal: the two chars of a surrogate pair cannot share its cell
- fTerminal.setChar(line, fCursorColumn, '\uFFFD', fStyle);
- } else if (charsUsed == 2) {
- fTerminal.setChars(line, fCursorColumn, chars, i, 2, fStyle);
- } else {
- fTerminal.setChar(line, fCursorColumn, chars[i], fStyle);
- if (width == 2) {
- fTerminal.setChar(line, fCursorColumn + 1, '\000', fStyle);
- }
- }
- col = fCursorColumn + width;
- i += charsUsed;
+ char last = chars[i - 1];
+ line = written(col + cells - CharWidth.of(last), String.valueOf(last), col + cells);
+ continue;
}
- // wrap needed?
- if (col == fColumns) {
- if (fVT100LineWrapping) {
- // deferred line wrapping (eat_newline_glitch)
- setCursorColumn(col - 1);
- fWrapPending = true;
- } else {
+ String text = Character.toString(codePoint);
+ cells = CharWidth.clusterCells(text);
+ if (cells > room) {
+ if (col > 0) {
+ // a wide character is never split across the right margin,
+ // and the cell it leaves unused there is erased
+ eraseWhole(line, col, fColumns);
line = doLineWrap();
+ continue;
}
- } else {
- setCursorColumn(col);
+ cells = room; // a screen narrower than the character
}
+ if (fInsertMode) {
+ insertCharacters(cells); // room in cells, not characters
+ }
+ put(line, col, text, cells, fStyle);
+ i += charsUsed;
+ line = written(col, text, col + cells);
}
}
}
/**
- * A wide character owns two cells. Overwriting either one leaves the other
- * stranded: a filler with nothing in front of it, or a glyph that now spills
- * over whatever was written next to it. Blanking the partner before the write
- * goes in keeps the line honest, which is what a terminal is expected to do.
+ * Where a grapheme cluster starts in the text, by the Unicode rules (UAX #29),
+ * the cluster written last going first as the context the text goes on from.
+ *
+ * @return for each char of the text, whether a cluster starts there
*/
- private void breakWideChar(int line, int col) {
- if (col < 0 || col >= fColumns) {
- return;
+ private boolean[] clusterStarts(String before, String text) {
+ boolean[] starts = new boolean[text.length()];
+ int simple = before.isEmpty() || startsAlways(before.charAt(before.length() - 1)) ? 0 : -1;
+ while (simple >= 0 && simple < starts.length && startsAlways(text.charAt(simple))) {
+ starts[simple++] = true;
+ }
+ if (simple == starts.length) {
+ return starts; // most text is nothing else
}
- char c = fTerminal.getChar(line, col);
- if (Character.isLowSurrogate(c)) {
- // second cell of a character beyond the BMP
- if (col > 0 && Character.isHighSurrogate(fTerminal.getChar(line, col - 1))) {
- blank(line, col - 1);
+ fGraphemes.setText(before + text);
+ int b = fGraphemes.following(Math.max(before.length() - 1, 0));
+ for (; b != BreakIterator.DONE; b = fGraphemes.next()) {
+ if (b >= before.length() && b - before.length() < starts.length) {
+ starts[b - before.length()] = true;
}
- } else if (Character.isHighSurrogate(c)) {
- if (col + 1 < fColumns && Character.isLowSurrogate(fTerminal.getChar(line, col + 1))) {
- blank(line, col + 1);
+ }
+ if (before.isEmpty() && starts.length > 0) {
+ starts[0] = true;
+ }
+ return starts;
+ }
+
+ /** the longest cluster kept, in chars: the longest emoji sequence is 15 */
+ private static final int MAX_CLUSTER = 32;
+
+ /**
+ * The text without what is kept out of the cells: control characters, format
+ * characters no cluster needs (a zero width space, bidirectional marks and
+ * overrides), and what only hides text: tag characters outside the tag
+ * sequence of a flag, and a variation selector that does not follow a
+ * character it could vary. None is drawn, and in text copied from the
+ * terminal it could make a command read differently from how it runs, or
+ * carry text no one saw. Taken out before the clusters are found, so that
+ * where the text was split into writes makes no difference.
+ */
+ private String withoutHidden(String text) {
+ int i = 0;
+ while (i < text.length() && CharWidth.of(text.charAt(i)) != 0 && !Character.isSurrogate(text.charAt(i))) {
+ i++;
+ }
+ if (i == text.length()) {
+ return text; // nothing that could be: most text
+ }
+ StringBuilder kept = new StringBuilder(text.length()).append(text, 0, i);
+ String before = i > 0 ? text.substring(0, i) : lastCluster() >= 0 ? fLastText : ""; //$NON-NLS-1$
+ int last = before.isEmpty() ? -1 : before.codePointBefore(before.length());
+ int tags = 0; // tag characters of a flag so far
+ for (int k = before.length(); k > 0 && isTagSpec(before.codePointBefore(k)); k -= 2) {
+ tags++;
+ }
+ for (; i < text.length(); i += Character.charCount(text.codePointAt(i))) {
+ int codePoint = text.codePointAt(i);
+ if (!isHidden(codePoint, last, tags)) {
+ kept.appendCodePoint(codePoint);
+ tags = isTagSpec(codePoint) ? tags + 1 : 0;
+ last = codePoint;
}
- } else if (c == '\000') {
- if (col > 0 && CharWidth.of(fTerminal.getChar(line, col - 1)) == 2) {
- blank(line, col - 1);
+ }
+ return kept.toString();
+ }
+
+ /**
+ * @param last the code point kept before it
+ * @param tags how many tag characters of a flag are right before it
+ * @return whether the code point is kept out of the cells
+ */
+ private boolean isHidden(int codePoint, int last, int tags) {
+ int type = Character.getType(codePoint);
+ if (type == Character.CONTROL) {
+ return true;
+ }
+ if (isSelector(codePoint)) {
+ return last < 0 || CharWidth.cells(last) == 0; // it varies or joins the character before it
+ }
+ if (type != Character.FORMAT || codePoint == 0x200D || codePoint == 0x200C) {
+ return false;
+ }
+ if (isTagSpec(codePoint)) {
+ // a flag spelled with tags (UTS #51): the black flag, up to six letters or digits, a cancel tag
+ return !(last == 0x1F3F4 || tags > 0) || tags >= 6;
+ }
+ if (codePoint == 0xE007F) {
+ return tags == 0;
+ }
+ // a prefix sign goes with what follows it, but one is enough
+ String letter = "a"; //$NON-NLS-1$
+ return breaks(Character.toString(codePoint), letter)
+ || last >= 0 && Character.getType(last) == Character.FORMAT && !breaks(Character.toString(last), letter);
+ }
+
+ /**
+ * @return whether the code point is invisible and only says how to show the
+ * character before it: a variation selector, a Mongolian free variation
+ * selector (not U+180E between them, a format character); the combining
+ * grapheme joiner is not one, it keeps marks apart (Unicode 23.2)
+ */
+ private static boolean isSelector(int codePoint) {
+ return codePoint >= 0xFE00 && codePoint <= 0xFE0F || codePoint >= 0xE0100 && codePoint <= 0xE01EF
+ || codePoint >= 0x180B && codePoint <= 0x180D || codePoint == 0x180F;
+ }
+
+ /** @return whether the code point is a tag letter or digit, as in the tag sequence of a flag */
+ private static boolean isTagSpec(int codePoint) {
+ return codePoint >= 0xE0030 && codePoint <= 0xE0039 || codePoint >= 0xE0061 && codePoint <= 0xE007A;
+ }
+
+ /** per char of the BMP: 0 not asked yet, 1 a cluster of its own next to any other such, 2 not */
+ private static final byte[] ALONE = new byte[0x10000];
+
+ /**
+ * Whether no rule of UAX #29 can join the char to the one before it or after it,
+ * when that one is such a char too: for a run of them, every char starts a
+ * cluster and the rules need not be run. Asked once of the rules themselves:
+ * nothing joins it after a letter or before one, after a Hangul syllable (a
+ * conjoining vowel or final would) or before one (a leading consonant would).
+ * Half of a surrogate pair and a control character are never such a char.
+ */
+ private boolean startsAlways(int c) {
+ if (ALONE[c] == 0) {
+ String one = String.valueOf((char) c);
+ String letter = "a"; //$NON-NLS-1$
+ String syllable = "\uac00"; //$NON-NLS-1$
+ boolean alone = !Character.isSurrogate((char) c) && Character.getType(c) != Character.CONTROL
+ && Character.getType(c) != Character.FORMAT && breaks(letter, one) && breaks(one, letter)
+ && breaks(syllable, one) && breaks(one, syllable);
+ ALONE[c] = (byte) (alone ? 1 : 2);
+ }
+ return ALONE[c] == 1;
+ }
+
+ /** Whatever is done but writing text: the cluster written last is done with. */
+ private void endCluster() {
+ synchronized (fTerminal) { // a resize comes from the display thread
+ fMoves++;
+ }
+ }
+
+ private boolean breaks(String first, String second) {
+ fGraphemes.setText(first + second);
+ return fGraphemes.isBoundary(first.length());
+ }
+
+ /**
+ * A cluster that starts with a code point that takes no cell, such as a zero
+ * width space or a mark the rules leave on its own, goes onto the cluster
+ * before it, which has a cell to keep it in. A prefix sign does not: it goes
+ * with what follows it. Decided by the code point alone, so that it does not
+ * matter where the text was split into writes.
+ */
+ private boolean takesNoCell(int codePoint) {
+ if (CharWidth.cells(codePoint) > 0) {
+ return false;
+ }
+ String prefix = Character.toString(codePoint);
+ fGraphemes.setText(prefix + "a"); //$NON-NLS-1$
+ return fGraphemes.isBoundary(prefix.length());
+ }
+
+ /**
+ * @return the line of the cluster written last, when what comes next can still go
+ * onto it: the cursor has not moved since, and the cells still hold it; -1
+ * otherwise
+ */
+ private int lastCluster() {
+ if (fLastText == null || fMoves != fAfterMoves || fWrapPending != fAfterPending
+ || fLastColumn >= fColumns) {
+ return -1;
+ }
+ int line = toAbsoluteLine(fCursorLine) - fLastLinesBack;
+ return line >= 0 && fLastText.equals(Cells.text(fTerminal, line, fLastColumn)) ? line : -1;
+ }
+
+ /**
+ * Moves the cursor past a character just written, whose cells end before
+ * end, and remembers it for what may still go onto it.
+ *
+ * @return the line the cursor is now on
+ */
+ private int written(int col, String text, int end) {
+ int line = advance(end);
+ fLastText = text;
+ fLastColumn = col;
+ fLastLinesBack = end == fColumns && !fVT100LineWrapping ? 1 : 0;
+ fAfterMoves = fMoves;
+ fAfterPending = fWrapPending;
+ return line;
+ }
+
+ /**
+ * A grapheme cluster is what a reader takes for one character, however many code
+ * points it runs to: a letter with its accents, a Hangul syllable sent as jamo,
+ * an emoji with a skin tone or joined to others, a flag. The code point goes on
+ * with the cluster written last. The cluster takes the cells its code points
+ * would, but at least one and no more than two, summed as Windows Terminal sums
+ * them: a mark takes none, VS16 (emoji presentation) makes it wide. Grown wide,
+ * it takes the cell after it, or moves whole to the next line from the last one.
+ *
+ * @return the line the cursor is on
+ */
+ private int goOn(String codePoint) {
+ if (fLastText.length() + codePoint.length() > MAX_CLUSTER) {
+ return toAbsoluteLine(fCursorLine); // a flood of marks must not grow one cell without bound
+ }
+ int line = lastCluster();
+ int col = fLastColumn;
+ String text = fLastText + codePoint;
+ int cells = fTerminal.getCellWidth(line, col);
+ TerminalStyle style = fTerminal.getStyle(line, col);
+ if (CharWidth.clusterCells(text) <= cells || fColumns < 2) {
+ put(line, col, text, cells, style);
+ fLastText = text; // in the same cells, with the cursor where it was
+ return toAbsoluteLine(fCursorLine);
+ }
+ if (col + 2 > fColumns) {
+ // on the last cell: a wide character is never split across the margin, so
+ // the cluster moves whole to the next line, the cell it leaves erased
+ boolean pending = fLastLinesBack == 0;
+ eraseCells(line, col, col + 1);
+ line = pending ? doLineWrap() : toAbsoluteLine(fCursorLine);
+ col = 0;
+ if (fInsertMode) {
+ insertCharacters(2);
+ }
+ } else if (fInsertMode) {
+ insertCharacters(1); // the second cell is room made, not a cell taken from what follows
+ }
+ put(line, col, text, 2, style);
+ return written(col, text, col + 2);
+ }
+
+ /**
+ * Writes a character, which may be a whole grapheme cluster, into its cells from
+ * col, the one way characters get into cells. The cell gets the
+ * character that is drawn: the first that takes a cell (not a prefix sign before
+ * it), composed with its marks (NFC) where they have a composed form; a second
+ * cell gets a filler, unless the character is a surrogate pair. The cluster, as
+ * the program wrote it, is kept beside the line whenever it is more than that.
+ * A character the write cuts in half is blanked whole.
+ */
+ private void put(int line, int col, String text, int cells, TerminalStyle style) {
+ breakAround(line, col, col + cells);
+ int base = ' ';
+ String composed = text.length() > Character.charCount(text.codePointAt(0))
+ ? Normalizer.normalize(text, Normalizer.Form.NFC)
+ : text;
+ for (int i = 0; i < composed.length(); i += Character.charCount(composed.codePointAt(i))) {
+ base = composed.codePointAt(i);
+ if (CharWidth.cells(base) > 0) {
+ break;
}
- } else if (CharWidth.of(c) == 2 && col + 1 < fColumns && fTerminal.getChar(line, col + 1) == '\000') {
- blank(line, col + 1);
+ base = ' ';
+ }
+ boolean lone = base >= Character.MIN_SURROGATE && base <= Character.MAX_SURROGATE; // half a pair
+ char[] chars = lone || Character.charCount(base) > cells ? new char[] { '\uFFFD' } : Character.toChars(base);
+ String drawn = new String(chars);
+ if (cells == 2 && chars.length == 1) {
+ chars = new char[] { chars[0], '\000' }; // the filler, written with it
+ }
+ fTerminal.setChars(line, col, chars, 0, chars.length, style);
+ if (!text.equals(drawn)) {
+ fTerminal.setCluster(line, col, text);
}
}
/**
- * Cells a code point takes on the screen: its display width, except that a
- * printable character beyond the BMP always takes two, since its two chars
- * cannot share a cell.
+ * A character that the cells from from to to cut in
+ * half is blanked whole, as a terminal is expected to do: half of it cannot be
+ * shown.
*/
- private static int cellWidth(int codePoint) {
- int width = CharWidth.of(codePoint);
- return width != 0 && Character.charCount(codePoint) == 2 ? 2 : width;
+ private void breakAround(int line, int from, int to) {
+ int first = Cells.start(fTerminal, line, from);
+ int last = Cells.start(fTerminal, line, to - 1);
+ int end = Math.min(last + fTerminal.getCellWidth(line, last), fColumns);
+ for (int col = first; col < from; col++) {
+ blank(line, col);
+ }
+ for (int col = to; col < end; col++) {
+ blank(line, col);
+ }
}
/**
- * @return whether the cell is the second of a wide character's two cells
+ * Moves the characters in the cells from from to to by
+ * shift cells, each written whole, with its cluster.
*/
- private boolean isSecondCell(int line, int col) {
- return col > 0 && col < fColumns && CharWidth.isSecondCell(
- String.valueOf(new char[] { fTerminal.getChar(line, col - 1), fTerminal.getChar(line, col) }), 1);
+ private void moveCells(int line, int from, int to, int shift) {
+ int n = to - from;
+ if (n <= 0) {
+ return;
+ }
+ // read all first: where they go may be where they were
+ char[] chars = new char[n];
+ TerminalStyle[] styles = new TerminalStyle[n];
+ String[] clusters = new String[n];
+ int[] widths = new int[n];
+ for (int i = 0; i < n; i++) {
+ chars[i] = fTerminal.getChar(line, from + i);
+ styles[i] = fTerminal.getStyle(line, from + i);
+ clusters[i] = fTerminal.getCluster(line, from + i);
+ widths[i] = fTerminal.getCellWidth(line, from + i);
+ }
+ for (int i = 0; i < n;) {
+ int width = i + widths[i] <= n ? Math.max(widths[i], 1) : 1;
+ fTerminal.setChars(line, from + shift + i, chars, i, width, styles[i]);
+ if (clusters[i] != null) {
+ fTerminal.setCluster(line, from + shift + i, clusters[i]);
+ }
+ i += width;
+ }
}
private void blank(int line, int col) {
@@ -707,18 +1005,23 @@ private void blank(int line, int col) {
}
/**
- * Length of the run of characters starting at offset that each
- * occupy exactly one cell, so that they can be copied in one block. Capped at
- * max cells. Zero when the run does not start with such a
- * character, which sends the caller down the code point by code point path.
+ * Moves the cursor past what was just written, which ends before col.
+ *
+ * @return the line the cursor is now on
*/
- private static int narrowRun(char[] chars, int offset, int max) {
- int n = 0;
- while (n < max && offset + n < chars.length && !Character.isSurrogate(chars[offset + n])
- && CharWidth.of(chars[offset + n]) == 1) {
- n++;
+ private int advance(int col) {
+ if (col == fColumns) {
+ if (fVT100LineWrapping) {
+ // deferred line wrapping (eat_newline_glitch)
+ setCursorColumn(col - 1);
+ fWrapPending = true;
+ } else {
+ return doLineWrap();
+ }
+ } else {
+ setCursorColumn(col);
}
- return n;
+ return toAbsoluteLine(fCursorLine);
}
private int doLineWrap() {
@@ -751,6 +1054,7 @@ private void doNewline() {
@Override
public void processNewline() {
+ endCluster();
synchronized (fTerminal) {
doNewline();
}
@@ -767,6 +1071,7 @@ private void doReverseLineFeed() {
@Override
public void processReverseLineFeed() {
+ endCluster();
synchronized (fTerminal) {
doReverseLineFeed();
}
@@ -788,6 +1093,7 @@ public int getCursorColumn() {
@Override
public void setCursor(int targetLine, int targetColumn) {
+ endCluster();
synchronized (fTerminal) {
setCursorLine(targetLine);
setCursorColumn(targetColumn);
@@ -804,6 +1110,7 @@ public void setCursorColumn(int targetColumn) {
}
fCursorColumn = targetColumn;
fWrapPending = false;
+ endCluster(); // a cursor moved away and back no longer goes on with what it wrote
// We make the assumption that nobody is changing the
// terminal cursor except this class!
// This assumption gives a huge performance improvement
@@ -821,6 +1128,7 @@ public void setCursorLine(int targetLine) {
}
fCursorLine = targetLine;
fWrapPending = false;
+ endCluster();
// We make the assumption that nobody is changing the
// terminal cursor except this class!
// This assumption gives a huge performance improvement
@@ -868,6 +1176,7 @@ public void setScrollRegion(int top, int bottom) {
@Override
public void scrollUp(int n) {
+ endCluster();
assert n > 0;
synchronized (fTerminal) {
int line = toAbsoluteLine(fScrollRegion.getTopLine());
@@ -878,6 +1187,7 @@ public void scrollUp(int n) {
@Override
public void scrollDown(int n) {
+ endCluster();
assert n > 0;
synchronized (fTerminal) {
int line = toAbsoluteLine(fScrollRegion.getTopLine());
@@ -888,6 +1198,7 @@ public void scrollDown(int n) {
@Override
public void eraseCharacters(int n) {
+ endCluster();
if (n <= 0) {
return; // nothing is erased, so no wide character may be broken either
}
diff --git a/terminal/bundles/org.eclipse.terminal.control/src/org/eclipse/terminal/internal/model/Cells.java b/terminal/bundles/org.eclipse.terminal.control/src/org/eclipse/terminal/internal/model/Cells.java
new file mode 100644
index 00000000000..ade0c01b578
--- /dev/null
+++ b/terminal/bundles/org.eclipse.terminal.control/src/org/eclipse/terminal/internal/model/Cells.java
@@ -0,0 +1,171 @@
+/*******************************************************************************
+ * Copyright (c) 2026 Contributors to the Eclipse Foundation.
+ * All rights reserved. This program and the accompanying materials
+ * are made available under the terms of the Eclipse Public License 2.0
+ * which accompanies this distribution, and is available at
+ * https://www.eclipse.org/legal/epl-2.0/
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ *******************************************************************************/
+package org.eclipse.terminal.internal.model;
+
+import java.text.Normalizer;
+
+import org.eclipse.terminal.model.ITerminalTextDataReadOnly;
+
+/**
+ * The characters of a line as its cells hold them, for everything that reads
+ * them: the selection, copying, the word under the pointer, drawing and the
+ * emulator. How many cells a character takes is kept by the text data
+ * ({@link ITerminalTextDataReadOnly#getCellWidth(int, int)}); this puts the
+ * character back together from them: a surrogate pair from its two cells, a
+ * grapheme cluster from beside the line
+ * ({@link ITerminalTextDataReadOnly#getCluster(int, int)}).
+ */
+public final class Cells {
+
+ private Cells() {
+ }
+
+ /** @return the first cell of the character the cell belongs to */
+ public static int start(ITerminalTextDataReadOnly data, int line, int col) {
+ return col > 0 && data.getCellWidth(line, col) == 0 ? col - 1 : col;
+ }
+
+ /**
+ * @return the character starting in the cell as the program wrote it: a whole
+ * grapheme cluster, both halves of a surrogate pair, or the one char (a
+ * replacement character for half a pair); "" for the second cell of a
+ * character and for a cell never written
+ */
+ public static String text(ITerminalTextDataReadOnly data, int line, int col) {
+ return text(data, line, col, false);
+ }
+
+ /**
+ * @param pastMargin whether the text goes on past the margin, where a narrowed
+ * screen keeps what it no longer shows: a pair the margin cuts is whole
+ * there
+ */
+ private static String text(ITerminalTextDataReadOnly data, int line, int col, boolean pastMargin) {
+ if (data.getCellWidth(line, col) == 0) {
+ return ""; //$NON-NLS-1$
+ }
+ String cluster = data.getCluster(line, col);
+ if (cluster != null) {
+ return cluster;
+ }
+ char c = charAt(data, line, col);
+ if (c == '\000') {
+ return ""; //$NON-NLS-1$
+ }
+ if (Character.isHighSurrogate(c) && (data.getCellWidth(line, col) == 2
+ || pastMargin && Character.isLowSurrogate(charAt(data, line, col + 1)))) {
+ return new String(new char[] { c, charAt(data, line, col + 1) });
+ }
+ // half a pair, its other half cut off by a narrowed screen, is no character
+ return Character.isSurrogate(c) ? "\uFFFD" : String.valueOf(c); //$NON-NLS-1$
+ }
+
+ /**
+ * @return the char in the cell, also one a narrowed screen keeps past its margin
+ * (0 where there is none)
+ */
+ private static char charAt(ITerminalTextDataReadOnly data, int line, int col) {
+ if (col < data.getWidth()) {
+ return data.getChar(line, col);
+ }
+ char[] chars = data.getChars(line);
+ return chars != null && col < chars.length ? chars[col] : 0;
+ }
+
+ /**
+ * The text of a whole row, also what a narrowed screen keeps past its margin and
+ * shows again when it widens.
+ */
+ public static String row(ITerminalTextDataReadOnly data, int line, int from) {
+ char[] chars = data.getChars(line);
+ int to = Math.max(data.getWidth(), chars == null ? 0 : chars.length);
+ return text(data, line, from, to, to > data.getWidth());
+ }
+
+ /**
+ * @return the character starting in the cell as it is drawn: a cluster with its
+ * marks composed where they have a composed form (NFC)
+ */
+ public static String drawn(ITerminalTextDataReadOnly data, int line, int col) {
+ String text = text(data, line, col);
+ return text.length() > 1 ? Normalizer.normalize(text, Normalizer.Form.NFC) : text;
+ }
+
+ /**
+ * The characters starting in the cells from from to
+ * to as they are drawn, a cell never written as a space: text that
+ * lines up cell for cell as long as each character is drawn as wide as the cells
+ * it takes.
+ *
+ * @param cells the chars of those cells, as
+ * {@link ITerminalTextDataReadOnly#getLineSegments(int, int, int)}
+ * has them
+ * @return the text, or null when a character the range starts or ends in is cut
+ * by it
+ */
+ public static String drawn(ITerminalTextDataReadOnly data, int line, int from, String cells) {
+ if (data.isSimpleLine(line)) {
+ return cells.replace('\000', ' '); // the chars are the text
+ }
+ int to = from + cells.length();
+ if (data.getCellWidth(line, from) == 0) {
+ return null;
+ }
+ StringBuilder drawn = new StringBuilder(cells.length());
+ for (int col = from; col < to;) {
+ int width = Math.max(data.getCellWidth(line, col), 1); // always on, whatever the cells hold
+ if (col + width > to) {
+ return null;
+ }
+ String cluster = data.getCluster(line, col);
+ char c = cells.charAt(col - from);
+ if (cluster != null) {
+ drawn.append(Normalizer.normalize(cluster, Normalizer.Form.NFC));
+ } else if (c == '\000') {
+ drawn.append(' ');
+ } else {
+ drawn.append(c);
+ if (width == 2 && Character.isHighSurrogate(c)) {
+ drawn.append(cells.charAt(col - from + 1));
+ }
+ }
+ col += width;
+ }
+ return drawn.toString();
+ }
+
+ /**
+ * The text of the characters starting in the cells from from to
+ * to: a cell never written stands for a space, and those at the
+ * end of the range are left out.
+ */
+ public static String text(ITerminalTextDataReadOnly data, int line, int from, int to) {
+ return text(data, line, from, to, false);
+ }
+
+ private static String text(ITerminalTextDataReadOnly data, int line, int from, int to, boolean pastMargin) {
+ StringBuilder text = new StringBuilder(to - from);
+ int written = 0;
+ for (int col = from; col < to; col++) {
+ if (data.getCellWidth(line, col) == 0) {
+ continue;
+ }
+ String c = text(data, line, col, pastMargin);
+ if (c.isEmpty()) {
+ text.append(' ');
+ } else {
+ text.append(c);
+ written = text.length();
+ }
+ }
+ text.setLength(written);
+ return text.toString();
+ }
+}
diff --git a/terminal/bundles/org.eclipse.terminal.control/src/org/eclipse/terminal/internal/model/CharWidth.java b/terminal/bundles/org.eclipse.terminal.control/src/org/eclipse/terminal/internal/model/CharWidth.java
index 5d7a511f76a..c95e518cb52 100644
--- a/terminal/bundles/org.eclipse.terminal.control/src/org/eclipse/terminal/internal/model/CharWidth.java
+++ b/terminal/bundles/org.eclipse.terminal.control/src/org/eclipse/terminal/internal/model/CharWidth.java
@@ -62,45 +62,53 @@ private CharWidth() {
0x1FAEF, 0x1FAF8, 0x20000, 0x2FFFD, 0x30000, 0x3FFFD
};
+ /** the width of every character of the BMP, looked up rather than worked out for each one written */
+ private static final byte[] BMP = new byte[0x10000];
+ static {
+ for (int c = 0; c < BMP.length; c++) {
+ BMP[c] = (byte) compute(c);
+ }
+ }
+
/** @return 0 for combining and non-printing, 2 for East Asian W/F, else 1 */
public static int of(int codePoint) {
+ return codePoint < BMP.length ? BMP[codePoint] : compute(codePoint);
+ }
+
+ private static int compute(int codePoint) {
if (codePoint < 0x0080) {
return codePoint < 0x20 || codePoint == 0x7F ? 0 : 1;
}
return isZeroWidth(codePoint) ? 0 : isWide(codePoint) ? 2 : 1;
}
- /** @return the total display width of {@code s} */
- public static int ofString(String s) {
- return s.codePoints().map(CharWidth::of).sum();
+ /**
+ * Cells a code point takes by itself: its width, beyond the BMP as well. A
+ * narrow one there, such as a mathematical letter, takes one cell, which keeps
+ * it as a cluster beside the line since its two chars cannot share the cell.
+ */
+ public static int cells(int codePoint) {
+ return of(codePoint);
}
/**
- * A {@code '\000'} means one of two things in a line of cells: the filler that
- * a wide character puts in the cell it also covers, which carries no text of
- * its own, or a cell that was never written or has been erased, which reads as
- * a space. Only a wide character of the BMP leaves a filler: one beyond the
- * BMP already covers its two cells with its two surrogates, so a null after it
- * is an ordinary empty cell.
- *
- * @return whether the cell at {@code index} is the filler of the character
- * before it
+ * Cells a grapheme cluster takes, summed as Windows Terminal sums them: what its
+ * code points take, VS16 (emoji presentation) counting as two, but at least one
+ * and no more than two.
*/
- public static boolean isFiller(CharSequence text, int index) {
- if (index == 0 || text.charAt(index) != '\000') {
- return false;
+ public static int clusterCells(String cluster) {
+ int cells = 0;
+ for (int i = 0; i < cluster.length();) {
+ int codePoint = cluster.codePointAt(i);
+ cells += codePoint == 0xFE0F ? 2 : cells(codePoint);
+ i += Character.charCount(codePoint);
}
- char before = text.charAt(index - 1);
- return !Character.isSurrogate(before) && of(before) == 2;
+ return Math.max(1, Math.min(2, cells));
}
- /**
- * @return whether the cell at {@code index} is the second of the two cells of
- * a wide character: its filler, or the low half of a surrogate pair
- */
- public static boolean isSecondCell(CharSequence text, int index) {
- return isFiller(text, index) || index > 0 && Character.isLowSurrogate(text.charAt(index))
- && Character.isHighSurrogate(text.charAt(index - 1));
+ /** @return the total display width of {@code s} */
+ public static int ofString(String s) {
+ return s.codePoints().map(CharWidth::of).sum();
}
private static boolean isZeroWidth(int codePoint) {
diff --git a/terminal/bundles/org.eclipse.terminal.control/src/org/eclipse/terminal/internal/model/SynchronizedTerminalTextData.java b/terminal/bundles/org.eclipse.terminal.control/src/org/eclipse/terminal/internal/model/SynchronizedTerminalTextData.java
index 3de26a78cb4..b04b3b2b857 100644
--- a/terminal/bundles/org.eclipse.terminal.control/src/org/eclipse/terminal/internal/model/SynchronizedTerminalTextData.java
+++ b/terminal/bundles/org.eclipse.terminal.control/src/org/eclipse/terminal/internal/model/SynchronizedTerminalTextData.java
@@ -162,4 +162,24 @@ synchronized public void setWrappedLine(int line) {
synchronized public void clearWrappedLine(int line) {
fData.clearWrappedLine(line);
}
+
+ @Override
+ synchronized public String getCluster(int line, int column) {
+ return fData.getCluster(line, column);
+ }
+
+ @Override
+ synchronized public int getCellWidth(int line, int column) {
+ return fData.getCellWidth(line, column);
+ }
+
+ @Override
+ synchronized public boolean isSimpleLine(int line) {
+ return fData.isSimpleLine(line);
+ }
+
+ @Override
+ synchronized public void setCluster(int line, int column, String cluster) {
+ fData.setCluster(line, column, cluster);
+ }
}
diff --git a/terminal/bundles/org.eclipse.terminal.control/src/org/eclipse/terminal/internal/model/TerminalTextData.java b/terminal/bundles/org.eclipse.terminal.control/src/org/eclipse/terminal/internal/model/TerminalTextData.java
index b4cae86bc03..a385c387759 100644
--- a/terminal/bundles/org.eclipse.terminal.control/src/org/eclipse/terminal/internal/model/TerminalTextData.java
+++ b/terminal/bundles/org.eclipse.terminal.control/src/org/eclipse/terminal/internal/model/TerminalTextData.java
@@ -348,4 +348,25 @@ public void clearWrappedLine(int line) {
fData.clearWrappedLine(line);
sendLineChangedToSnapshots(line);
}
+
+ @Override
+ public String getCluster(int line, int column) {
+ return fData.getCluster(line, column);
+ }
+
+ @Override
+ public int getCellWidth(int line, int column) {
+ return fData.getCellWidth(line, column);
+ }
+
+ @Override
+ public boolean isSimpleLine(int line) {
+ return fData.isSimpleLine(line);
+ }
+
+ @Override
+ public void setCluster(int line, int column, String cluster) {
+ fData.setCluster(line, column, cluster);
+ sendLineChangedToSnapshots(line);
+ }
}
diff --git a/terminal/bundles/org.eclipse.terminal.control/src/org/eclipse/terminal/internal/model/TerminalTextDataFastScroll.java b/terminal/bundles/org.eclipse.terminal.control/src/org/eclipse/terminal/internal/model/TerminalTextDataFastScroll.java
index db68f309069..aabe1f2cbd5 100644
--- a/terminal/bundles/org.eclipse.terminal.control/src/org/eclipse/terminal/internal/model/TerminalTextDataFastScroll.java
+++ b/terminal/bundles/org.eclipse.terminal.control/src/org/eclipse/terminal/internal/model/TerminalTextDataFastScroll.java
@@ -302,6 +302,30 @@ private void validateLineParameter(int line) {
}
}
+ @Override
+ public String getCluster(int line, int column) {
+ validateLineParameter(line);
+ return fData.getCluster(getPositionOfLine(line), column);
+ }
+
+ @Override
+ public int getCellWidth(int line, int column) {
+ validateLineParameter(line);
+ return fData.getCellWidth(getPositionOfLine(line), column);
+ }
+
+ @Override
+ public boolean isSimpleLine(int line) {
+ validateLineParameter(line);
+ return fData.isSimpleLine(getPositionOfLine(line));
+ }
+
+ @Override
+ public void setCluster(int line, int column, String cluster) {
+ validateLineParameter(line);
+ fData.setCluster(getPositionOfLine(line), column, cluster);
+ }
+
@Override
public void setWrappedLine(int line) {
validateLineParameter(line);
diff --git a/terminal/bundles/org.eclipse.terminal.control/src/org/eclipse/terminal/internal/model/TerminalTextDataSnapshot.java b/terminal/bundles/org.eclipse.terminal.control/src/org/eclipse/terminal/internal/model/TerminalTextDataSnapshot.java
index 2a1688523eb..bf9ef8c04bf 100644
--- a/terminal/bundles/org.eclipse.terminal.control/src/org/eclipse/terminal/internal/model/TerminalTextDataSnapshot.java
+++ b/terminal/bundles/org.eclipse.terminal.control/src/org/eclipse/terminal/internal/model/TerminalTextDataSnapshot.java
@@ -318,4 +318,19 @@ public ITerminalTextData getTerminalTextData() {
public boolean isWrappedLine(int line) {
return fSnapshot.isWrappedLine(line);
}
+
+ @Override
+ public String getCluster(int line, int column) {
+ return fSnapshot.getCluster(line, column);
+ }
+
+ @Override
+ public int getCellWidth(int line, int column) {
+ return fSnapshot.getCellWidth(line, column);
+ }
+
+ @Override
+ public boolean isSimpleLine(int line) {
+ return fSnapshot.isSimpleLine(line);
+ }
}
diff --git a/terminal/bundles/org.eclipse.terminal.control/src/org/eclipse/terminal/internal/model/TerminalTextDataStore.java b/terminal/bundles/org.eclipse.terminal.control/src/org/eclipse/terminal/internal/model/TerminalTextDataStore.java
index d0f3ac2b398..4d5c03a7dad 100644
--- a/terminal/bundles/org.eclipse.terminal.control/src/org/eclipse/terminal/internal/model/TerminalTextDataStore.java
+++ b/terminal/bundles/org.eclipse.terminal.control/src/org/eclipse/terminal/internal/model/TerminalTextDataStore.java
@@ -13,8 +13,11 @@
import java.lang.reflect.Array;
import java.util.ArrayList;
+import java.util.Arrays;
import java.util.BitSet;
+import java.util.HashMap;
import java.util.List;
+import java.util.Map;
import org.eclipse.terminal.model.ITerminalTextData;
import org.eclipse.terminal.model.ITerminalTextDataSnapshot;
@@ -34,10 +37,24 @@ public class TerminalTextDataStore implements ITerminalTextData {
private int fCursorColumn;
private int fCursorLine;
final private BitSet fWrappedLines = new BitSet();
+ /** per line, how its characters lie in its cells; null for a line of one-cell characters only */
+ private Layout[] fLayout;
+
+ /**
+ * Where a character takes two cells, and the grapheme clusters, of one line:
+ * set when the characters are written, so that reading it is a lookup.
+ */
+ private static final class Layout {
+ /** the cells that are the second of a character starting in the cell before */
+ final BitSet second = new BitSet();
+ /** the clusters, by first cell */
+ final Map clusters = new HashMap<>();
+ }
public TerminalTextDataStore() {
fChars = new char[0][];
fStyle = new TerminalStyle[0][];
+ fLayout = new Layout[0];
fWidth = 0;
}
@@ -67,6 +84,7 @@ public void setDimensions(int height, int width) {
}
fStyle = (TerminalStyle[][]) resizeArray(fStyle, height);
fChars = (char[][]) resizeArray(fChars, height);
+ fLayout = (Layout[]) resizeArray(fLayout, height);
}
// clean the new lines
if (height > fHeight) {
@@ -192,6 +210,7 @@ public void setChar(int line, int column, char c, TerminalStyle style) {
ensureLineLength(line, column + 1);
fChars[line][column] = c;
fStyle[line][column] = style;
+ layOut(line, column, 1);
}
@Override
@@ -206,9 +225,87 @@ public void setChars(int line, int column, char[] chars, int start, int len, Ter
"Parameter 'column' must be >= 0 and less than 'width' (current value '" + fWidth + "')"); //$NON-NLS-1$ //$NON-NLS-2$
}
ensureLineLength(line, column + len);
- for (int i = 0; i < len; i++) {
- fChars[line][column + i] = chars[i + start];
- fStyle[line][column + i] = style;
+ System.arraycopy(chars, start, fChars[line], column, len);
+ Arrays.fill(fStyle[line], column, column + len, style);
+ layOut(line, column, len);
+ }
+
+ /**
+ * The cells from column on were just written: what took them before
+ * is gone, a character cut in half by the write is one cell, and a character
+ * written together with what fills its second cell (a wide character and a
+ * null, the two halves of a surrogate pair) takes two.
+ */
+ private void layOut(int line, int column, int len) {
+ if (len == 0) {
+ return; // nothing written, so nothing laid out anew either
+ }
+ Layout layout = fLayout[line];
+ char[] chars = fChars[line];
+ int end = column + len;
+ if (layout == null) {
+ for (int col = column; col + 1 < end; col++) {
+ if (takesTwo(chars[col], chars[col + 1])) {
+ layout = fLayout[line] = new Layout();
+ break;
+ }
+ }
+ if (layout == null) {
+ return; // the usual case: one cell each, as before
+ }
+ }
+ if (column > 0 && layout.second.get(column)) {
+ layout.clusters.remove(column - 1); // its second cell is gone
+ }
+ layout.second.clear(column, end + 1);
+ for (int col = column; col < end && !layout.clusters.isEmpty(); col++) {
+ layout.clusters.remove(col);
+ }
+ for (int col = column; col + 1 < end; col++) {
+ if (takesTwo(chars[col], chars[col + 1])) {
+ layout.second.set(++col);
+ }
+ }
+ }
+
+ private static boolean takesTwo(char c, char next) {
+ return Character.isHighSurrogate(c) ? Character.isLowSurrogate(next) : next == '\000' && CharWidth.of(c) == 2;
+ }
+
+ @Override
+ public int getCellWidth(int line, int column) {
+ Layout layout = fLayout[line];
+ if (layout == null) {
+ return 1;
+ }
+ if (layout.second.get(column)) {
+ return 0;
+ }
+ return column + 1 < fWidth && layout.second.get(column + 1) ? 2 : 1;
+ }
+
+ @Override
+ public String getCluster(int line, int column) {
+ Layout layout = fLayout[line];
+ return layout == null || layout.clusters.isEmpty() ? null : layout.clusters.get(column);
+ }
+
+ @Override
+ public boolean isSimpleLine(int line) {
+ Layout layout = fLayout[line];
+ return layout == null || layout.second.isEmpty() && layout.clusters.isEmpty();
+ }
+
+ @Override
+ public void setCluster(int line, int column, String cluster) {
+ if (fLayout[line] == null) {
+ fLayout[line] = new Layout();
+ }
+ Layout layout = fLayout[line];
+ layout.clusters.put(column, cluster);
+ // a cluster written over two cells, its second one filled, takes both
+ if (column + 1 < fWidth && CharWidth.clusterCells(cluster) == 2 && !layout.second.get(column)) {
+ layout.second.set(column + 1);
}
}
@@ -224,6 +321,7 @@ public void scroll(int startLine, int size, int shift) {
for (int i = startLine; i < startLine + size + shift; i++) {
fChars[i] = fChars[i - shift];
fStyle[i] = fStyle[i - shift];
+ fLayout[i] = fLayout[i - shift];
fWrappedLines.set(i, fWrappedLines.get(i - shift));
}
// then clean the opened lines
@@ -233,6 +331,7 @@ public void scroll(int startLine, int size, int shift) {
for (int i = startLine + size - 1; i >= startLine && i - shift >= 0; i--) {
fChars[i] = fChars[i - shift];
fStyle[i] = fStyle[i - shift];
+ fLayout[i] = fLayout[i - shift];
fWrappedLines.set(i, fWrappedLines.get(i - shift));
}
cleanLines(startLine, Math.min(shift, getHeight() - startLine));
@@ -289,6 +388,7 @@ public void copy(ITerminalTextData source) {
if (getHeight() != n) {
fChars = new char[n][];
fStyle = new TerminalStyle[n][];
+ fLayout = new Layout[n];
}
for (int i = 0; i < n; i++) {
copyLine(source, i, i);
@@ -321,9 +421,27 @@ public void copyRange(ITerminalTextData source, int sourceStartLine, int destSta
@Override
public void copyLine(ITerminalTextData source, int sourceLine, int destLine) {
- fChars[destLine] = source.getChars(sourceLine);
+ char[] chars = source.getChars(sourceLine);
+ // read before writing: the source may be this very line
+ Layout layout = null;
+ boolean simple = source.isSimpleLine(sourceLine);
+ for (int col = 0; !simple && chars != null && col < chars.length; col++) {
+ String cluster = source.getCluster(sourceLine, col);
+ int width = source.getCellWidth(sourceLine, col);
+ if (cluster != null || width == 0) {
+ if (layout == null) {
+ layout = new Layout();
+ }
+ if (cluster != null) {
+ layout.clusters.put(col, cluster);
+ }
+ layout.second.set(col, width == 0);
+ }
+ }
+ fChars[destLine] = chars;
fStyle[destLine] = source.getStyles(sourceLine);
fWrappedLines.set(destLine, source.isWrappedLine(sourceLine));
+ fLayout[destLine] = layout;
}
@Override
@@ -346,6 +464,8 @@ public void setLine(int line, char[] chars, TerminalStyle[] styles) {
fChars[line] = chars.clone();
fStyle[line] = styles.clone();
fWrappedLines.clear(line);
+ fLayout[line] = null;
+ layOut(line, 0, chars.length);
}
@Override
@@ -363,6 +483,7 @@ public void cleanLine(int line) {
fChars[line] = null;
fStyle[line] = null;
fWrappedLines.clear(line);
+ fLayout[line] = null;
}
@Override
diff --git a/terminal/bundles/org.eclipse.terminal.control/src/org/eclipse/terminal/internal/model/TerminalTextDataWindow.java b/terminal/bundles/org.eclipse.terminal.control/src/org/eclipse/terminal/internal/model/TerminalTextDataWindow.java
index 92149232a88..713ff397c3b 100644
--- a/terminal/bundles/org.eclipse.terminal.control/src/org/eclipse/terminal/internal/model/TerminalTextDataWindow.java
+++ b/terminal/bundles/org.eclipse.terminal.control/src/org/eclipse/terminal/internal/model/TerminalTextDataWindow.java
@@ -261,6 +261,28 @@ public boolean isWrappedLine(int line) {
return false;
}
+ @Override
+ public String getCluster(int line, int column) {
+ return isInWindow(line) ? fData.getCluster(line - fWindowStartLine, column) : null;
+ }
+
+ @Override
+ public int getCellWidth(int line, int column) {
+ return isInWindow(line) ? fData.getCellWidth(line - fWindowStartLine, column) : 1;
+ }
+
+ @Override
+ public boolean isSimpleLine(int line) {
+ return !isInWindow(line) || fData.isSimpleLine(line - fWindowStartLine);
+ }
+
+ @Override
+ public void setCluster(int line, int column, String cluster) {
+ if (isInWindow(line)) {
+ fData.setCluster(line - fWindowStartLine, column, cluster);
+ }
+ }
+
@Override
public void setWrappedLine(int line) {
if (isInWindow(line)) {
diff --git a/terminal/bundles/org.eclipse.terminal.control/src/org/eclipse/terminal/internal/textcanvas/AbstractTextCanvasModel.java b/terminal/bundles/org.eclipse.terminal.control/src/org/eclipse/terminal/internal/textcanvas/AbstractTextCanvasModel.java
index 4792864b406..644263759b5 100644
--- a/terminal/bundles/org.eclipse.terminal.control/src/org/eclipse/terminal/internal/textcanvas/AbstractTextCanvasModel.java
+++ b/terminal/bundles/org.eclipse.terminal.control/src/org/eclipse/terminal/internal/textcanvas/AbstractTextCanvasModel.java
@@ -21,7 +21,7 @@
import org.eclipse.core.runtime.Platform;
import org.eclipse.swt.graphics.Point;
import org.eclipse.terminal.connector.Logger;
-import org.eclipse.terminal.internal.model.CharWidth;
+import org.eclipse.terminal.internal.model.Cells;
import org.eclipse.terminal.model.ITerminalTextDataReadOnly;
import org.eclipse.terminal.model.ITerminalTextDataSnapshot;
import org.eclipse.terminal.model.TextRange;
@@ -299,11 +299,12 @@ private void doSetSelection(int startLine, int endLine, int startColumn, int end
// A wide character is selected whole: a selection that starts on its
// second cell starts on its first, one that ends on its first ends on
// its second.
- if (isSecondCell(startLine, startColumn)) {
- startColumn--;
+ if (inside(startLine, startColumn)) {
+ startColumn = Cells.start(fSelectionSnapshot, startLine, startColumn);
}
- if (isSecondCell(endLine, endColumn + 1)) {
- endColumn++;
+ if (inside(endLine, endColumn)) {
+ int start = Cells.start(fSelectionSnapshot, endLine, endColumn);
+ endColumn = start + fSelectionSnapshot.getCellWidth(endLine, start) - 1;
}
}
int oldStart = fSelectionStartLine;
@@ -383,7 +384,7 @@ public void expandHoverSelectionAt(final int line, final int col) {
return;
}
fHoverRange = TextRange.EMPTY;
- if (line < 0 || line > fSnapshot.getHeight() || col < 0) {
+ if (line < 0 || line >= fSnapshot.getHeight() || col < 0) {
return;
}
int row1 = line;
@@ -391,45 +392,50 @@ public void expandHoverSelectionAt(final int line, final int col) {
while (row1 > 0 && fSnapshot.isWrappedLine(row1 - 1)) {
row1--;
}
- while (row2 < fSnapshot.getHeight() && fSnapshot.isWrappedLine(row2)) {
+ while (row2 + 1 < fSnapshot.getHeight() && fSnapshot.isWrappedLine(row2)) {
row2++;
}
row2++;
- String lineText = ""; //$NON-NLS-1$
+ int width = fSnapshot.getWidth();
+ // the first cells of the characters of the logical line, as row * width + column
+ int[] cells = new int[(row2 - row1) * width];
+ int n = 0;
+ int at = -1;
+ int under = col < width ? line * width + Cells.start(fSnapshot, line, col) : -1;
for (int l = row1; l < row2; l++) {
- char[] chars = fSnapshot.getChars(l);
- if (chars == null) {
+ if (fSnapshot.getChars(l) == null) {
return;
}
- lineText += String.valueOf(chars);
+ for (int c = 0; c < width; c++) {
+ if (fSnapshot.getCellWidth(l, c) > 0) {
+ if (l * width + c == under) {
+ at = n;
+ }
+ cells[n++] = l * width + c;
+ }
+ }
}
- int width = fSnapshot.getWidth();
- int col1 = col + (line - row1) * width;
- if (lineText.length() <= col1 || isBoundary(lineText, col1)) {
+ if (at < 0 || isBoundary(cells[at], width)) {
return;
}
- int wordStart = 0;
- int wordEnd = lineText.length();
- for (int c = col1; c >= 1; c--) {
- if (isBoundary(lineText, c - 1)) {
- wordStart = c;
- break;
- }
+ int first = at;
+ int last = at;
+ while (first > 0 && !isBoundary(cells[first - 1], width)) {
+ first--;
}
- for (int c = col1; c < lineText.length(); c++) {
- if (isBoundary(lineText, c)) {
- wordEnd = c;
- break;
- }
+ while (last + 1 < n && !isBoundary(cells[last + 1], width)) {
+ last++;
}
- if (wordStart < wordEnd) {
- fHoverRange = new TextRange(row1 + wordStart / width, row1 + (wordEnd - 1) / width + 1, (wordStart % width),
- (wordEnd - 1) % width + 1,
- lineText.substring(wordStart, wordEnd).replace("\000", "")); //$NON-NLS-1$ //$NON-NLS-2$
- if (DEBUG_HOVER) {
- System.out.format("hover: %s <- [%s,%s][%s,%s]\n", //$NON-NLS-1$
- fHoverRange, col, line, wordStart, wordEnd);
- }
+ StringBuilder word = new StringBuilder();
+ for (int k = first; k <= last; k++) {
+ word.append(Cells.text(fSnapshot, cells[k] / width, cells[k] % width));
+ }
+ int a = cells[first];
+ int b = cells[last];
+ fHoverRange = new TextRange(a / width, b / width + 1, a % width,
+ b % width + fSnapshot.getCellWidth(b / width, b % width), word.toString());
+ if (DEBUG_HOVER) {
+ System.out.format("hover: %s <- [%s,%s]\n", fHoverRange, col, line); //$NON-NLS-1$
}
}
@@ -439,45 +445,22 @@ public String getHoverSelectionText() {
}
/**
- * The filler in the second cell of a wide character is what its character is:
- * part of the word for a path with Hangul in it, which would otherwise end at
- * every syllable, and a boundary for an ideographic space (U+3000).
+ * A word ends at a cell never written, at whitespace, the wide ideographic space
+ * included, and at a quote.
*/
- private boolean isBoundary(String text, int index) {
- char c = text.charAt(CharWidth.isFiller(text, index) ? index - 1 : index);
- return Character.isWhitespace(c) || (c < '\u0020') || c == '"' || c == '\'';
- }
-
- // helper to sanitize text copied out of a snapshot
- private static String scrubLine(String text) {
- // get rid of the empty space at the end of the lines
- // text=text.replaceAll("\000+$",""); //$NON-NLS-1$//$NON-NLS-2$
- //
- int i = text.length() - 1;
- while (i >= 0 && text.charAt(i) == '\000') {
- i--;
- }
- text = text.substring(0, i + 1);
- //
- // null means space, unless it is the filler of a wide character
- StringBuilder scrubbed = new StringBuilder(text.length());
- for (int j = 0; j < text.length(); j++) {
- char c = text.charAt(j);
- if (c != '\000') {
- scrubbed.append(c);
- } else if (!CharWidth.isFiller(text, j)) {
- scrubbed.append(' ');
- }
+ private boolean isBoundary(int cell, int width) {
+ int line = cell / width;
+ int col = cell % width;
+ if (fSnapshot.getCellWidth(line, col) == 0) {
+ return true;
}
- return scrubbed.toString();
+ char c = fSnapshot.getChar(line, col); // what the cell shows, not a prefix written before it
+ return Character.isWhitespace(c) || c < '\u0020' || c == '"' || c == '\'';
}
- private boolean isSecondCell(int line, int col) {
- if (line < 0 || line >= fSelectionSnapshot.getHeight() || col <= 0) {
- return false;
- }
- char[] chars = fSelectionSnapshot.getChars(line);
- return chars != null && col < chars.length && CharWidth.isSecondCell(new String(chars), col);
+ /** @return whether the cell is on the selection's snapshot */
+ private boolean inside(int line, int col) {
+ return line >= 0 && line < fSelectionSnapshot.getHeight() && col >= 0 && col < fSelectionSnapshot.getWidth();
}
/**
@@ -493,14 +476,12 @@ private String extractSelectedText() {
String text;
char[] chars = fSelectionSnapshot.getChars(line);
if (chars != null) {
- text = new String(chars);
- if (line == fSeletionEndLine && fSelectionEndColumn >= 0) {
- text = text.substring(0, Math.min(fSelectionEndColumn + 1, text.length()));
- }
- if (line == fSelectionStartLine) {
- text = text.substring(Math.min(fSelectionStartCoumn, text.length()));
- }
- text = scrubLine(text);
+ int width = fSelectionSnapshot.getWidth();
+ int from = line == fSelectionStartLine ? Math.min(fSelectionStartCoumn, width) : 0;
+ // a row selected to its end comes whole, with what a narrowed screen keeps past its margin
+ text = line == fSeletionEndLine && fSelectionEndColumn >= 0 && fSelectionEndColumn + 1 < width
+ ? Cells.text(fSelectionSnapshot, line, from, Math.min(fSelectionEndColumn + 1, width))
+ : Cells.row(fSelectionSnapshot, line, from);
} else {
text = ""; //$NON-NLS-1$
}
@@ -561,7 +542,7 @@ public String getAllText() {
char[] chars = snapshot.getChars(line);
String text;
if (chars != null) {
- text = scrubLine(new String(chars)); // take care of NULs
+ text = Cells.row(snapshot, line, 0);
} else {
text = ""; //$NON-NLS-1$ null arrays represent empty lines
}
diff --git a/terminal/bundles/org.eclipse.terminal.control/src/org/eclipse/terminal/internal/textcanvas/TextCanvas.java b/terminal/bundles/org.eclipse.terminal.control/src/org/eclipse/terminal/internal/textcanvas/TextCanvas.java
index 11d7487ee77..b1f0c5d904a 100644
--- a/terminal/bundles/org.eclipse.terminal.control/src/org/eclipse/terminal/internal/textcanvas/TextCanvas.java
+++ b/terminal/bundles/org.eclipse.terminal.control/src/org/eclipse/terminal/internal/textcanvas/TextCanvas.java
@@ -43,7 +43,6 @@
import org.eclipse.swt.graphics.Rectangle;
import org.eclipse.swt.widgets.Composite;
import org.eclipse.terminal.control.ITerminalMouseListener;
-import org.eclipse.terminal.internal.model.CharWidth;
import org.eclipse.terminal.model.ITerminalTextDataReadOnly;
import org.eclipse.terminal.model.TerminalColor;
@@ -598,17 +597,8 @@ protected void drawLine(GC gc, int line, int x, int y, int colFirst, int colLast
*/
private boolean isFillerCell(int line, int col) {
ITerminalTextDataReadOnly text = fCellCanvasModel.getTerminalText();
- if (col <= 0 || col >= text.getWidth() || line < 0 || line >= text.getHeight()) {
- return false;
- }
- char c = text.getChar(line, col);
- char before = text.getChar(line, col - 1);
- // the filler of a wide character of the BMP, or the low half of a surrogate
- // pair, whose high half sits in the cell before
- if (Character.isLowSurrogate(c)) {
- return Character.isHighSurrogate(before);
- }
- return c == '\000' && !Character.isSurrogate(before) && CharWidth.of(before) == 2;
+ return col > 0 && col < text.getWidth() && line >= 0 && line < text.getHeight()
+ && text.getCellWidth(line, col) == 0;
}
@Override
diff --git a/terminal/bundles/org.eclipse.terminal.control/src/org/eclipse/terminal/internal/textcanvas/TextLineRenderer.java b/terminal/bundles/org.eclipse.terminal.control/src/org/eclipse/terminal/internal/textcanvas/TextLineRenderer.java
index 56c185363a1..f52bd71f330 100644
--- a/terminal/bundles/org.eclipse.terminal.control/src/org/eclipse/terminal/internal/textcanvas/TextLineRenderer.java
+++ b/terminal/bundles/org.eclipse.terminal.control/src/org/eclipse/terminal/internal/textcanvas/TextLineRenderer.java
@@ -27,7 +27,7 @@
import org.eclipse.swt.graphics.Point;
import org.eclipse.swt.graphics.Rectangle;
import org.eclipse.terminal.connector.Logger;
-import org.eclipse.terminal.internal.model.CharWidth;
+import org.eclipse.terminal.internal.model.Cells;
import org.eclipse.terminal.model.ITerminalTextDataReadOnly;
import org.eclipse.terminal.model.LineSegment;
import org.eclipse.terminal.model.TerminalColor;
@@ -73,8 +73,7 @@ public void drawLine(ITextCanvasModel model, GC gc, int line, int x, int y, int
LineSegment segment = segments[i];
TerminalStyle style = segment.getStyle();
setupGC(gc, style);
- String text = segment.getText();
- drawText(gc, x, y, colFirst, segment.getColumn(), text);
+ drawText(gc, line, x, y, colFirst, segment.getColumn(), segment.getText());
drawCursor(model, gc, line, x, y, colFirst);
}
if (fModel.hasHoverSelection(line)) {
@@ -122,8 +121,7 @@ public void drawLine(ITextCanvasModel model, GC gc, int line, int x, int y, int
}
len = Math.min(len, chars.length - offset);
if (len > 0) {
- String text = new String(chars, offset, len);
- drawText(gc, x, y, colFirst, offset, text);
+ drawText(gc, line, x, y, colFirst, offset, new String(chars, offset, len));
}
}
}
@@ -154,12 +152,8 @@ private void drawCursor(ITextCanvasModel model, GC gc, int row, int x, int y, in
if (cursorColumn < getTerminalText().getWidth()) {
// On a wide character the cursor covers both of its cells, whichever
// one it is on, as in xterm: half a glyph cannot be drawn reversed.
- char[] chars = getTerminalText().getChars(row);
- String line = chars == null ? "" : new String(chars); //$NON-NLS-1$
- if (cursorColumn < line.length() && CharWidth.isSecondCell(line, cursorColumn)) {
- cursorColumn--;
- }
- int cells = cursorColumn + 1 < line.length() && CharWidth.isSecondCell(line, cursorColumn + 1) ? 2 : 1;
+ cursorColumn = Cells.start(getTerminalText(), row, cursorColumn);
+ int cells = getTerminalText().getCellWidth(row, cursorColumn);
TerminalStyle style = getTerminalText().getStyle(row, cursorColumn);
if (style == null) {
// TODO make the cursor color customizable
@@ -167,103 +161,72 @@ private void drawCursor(ITextCanvasModel model, GC gc, int row, int x, int y, in
}
style = style.setReverse(!style.isReverse());
setupGC(gc, style);
- String text = cells == 2 ? line.substring(cursorColumn, cursorColumn + 2)
- : String.valueOf(getTerminalText().getChar(row, cursorColumn));
Rectangle clipping = gc.getClipping();
gc.setClipping(clipping.intersection(new Rectangle(x + (cursorColumn - colFirst) * getCellWidth(), y,
cells * getCellWidth(), getCellHeight())));
- drawText(gc, x, y, colFirst, cursorColumn, text);
+ char[] text = new char[cells];
+ for (int i = 0; i < cells; i++) {
+ text[i] = getTerminalText().getChar(row, cursorColumn + i); // a null past what was written
+ }
+ drawText(gc, row, x, y, colFirst, cursorColumn, new String(text));
gc.setClipping(clipping);
}
}
}
- private void drawText(GC gc, int x, int y, int colFirst, int col, String text) {
+ /**
+ * Draws the characters that start in the cells from col whose
+ * chars are cells, each as {@link Cells} has it: a cluster whole,
+ * over the cells it takes.
+ */
+ private void drawText(GC gc, int line, int x, int y, int colFirst, int col, String cells) {
+ ITerminalTextDataReadOnly data = getTerminalText();
+ cells = cells.substring(0, Math.max(0, Math.min(cells.length(), data.getWidth() - col)));
+ int end = col + cells.length();
int offset = (col - colFirst) * getCellWidth();
if (fStyleMap.isFontProportional()) {
- // draw the background
- // TODO why does this not work???????
- // gc.fillRectangle(x,y,fStyleMap.getFontWidth()*text.length(),fStyleMap.getFontHeight());
int xx = x + offset;
- for (int i = 0; i < text.length(); i++) {
- char c = text.charAt(i);
- int cells = cellsAt(text, i);
- // a character beyond the BMP is drawn whole, both surrogates at once
- int n = Character.charCount(text.codePointAt(i));
+ for (int c = col; c < end;) {
+ int width = Math.max(data.getCellWidth(line, c), 1);
// TODO why do I have to draw the background character by character??????
- gc.fillRectangle(xx, y, cells * fStyleMap.getFontWidth(), fStyleMap.getFontHeight());
- if (c != ' ' && c != '\000') {
- gc.drawString(text.substring(i, i + n), fStyleMap.getCharOffset(c) + xx, y, false);
+ gc.fillRectangle(xx, y, width * fStyleMap.getFontWidth(), fStyleMap.getFontHeight());
+ String drawn = Cells.drawn(data, line, c);
+ if (!drawn.isBlank()) {
+ // placed by the character the cell shows, not a prefix written before it
+ gc.drawString(drawn, fStyleMap.getCharOffset(data.getChar(line, c)) + xx, y, false);
}
- xx += cells * fStyleMap.getFontWidth();
- i += n - 1;
+ xx += width * fStyleMap.getFontWidth();
+ c += width;
}
+ return;
+ }
+ // One call keeps whatever the font does with the run, ligatures included,
+ // but only while it advances exactly one cell per column. A character the
+ // font does not have is drawn from somewhere else and rarely does, and
+ // then everything after it on the line sits in the wrong column.
+ String drawn = Cells.drawn(data, line, col, cells);
+ if (drawn != null && gc.textExtent(drawn).x == (end - col) * getCellWidth()) {
+ gc.drawString(drawn, x + offset, y, false);
} else {
- // One call keeps whatever the font does with the run, ligatures included,
- // but only while it advances exactly one cell per column. A character the
- // font does not have is drawn from somewhere else and rarely does, and
- // then everything after it on the line sits in the wrong column.
- String drawn = withoutFillers(text);
- if (gc.textExtent(drawn).x == text.length() * getCellWidth()) {
- gc.drawString(drawn, x + offset, y, false);
- } else {
- drawCellByCell(gc, x + offset, y, text);
- }
+ drawCellByCell(gc, data, line, col, end, x + offset, y);
}
}
/**
* Puts every character at the start of its own cell, so the columns hold no
- * matter what the font makes of it. A wide character is left to cover the cell
- * of the filler that follows it.
+ * matter what the font makes of it. A character that takes two cells is left to
+ * cover the second one.
*/
- private void drawCellByCell(GC gc, int x, int y, String text) {
+ private void drawCellByCell(GC gc, ITerminalTextDataReadOnly data, int line, int col, int end, int x, int y) {
// The whole run at once, because the characters are drawn over it one at a
// time and the cells between them would otherwise keep what was there before.
- gc.fillRectangle(x, y, text.length() * getCellWidth(), getCellHeight());
- for (int i = 0; i < text.length();) {
- // A character beyond the BMP is two chars in two cells, and has to be
- // drawn whole: half of a surrogate pair is no character at all.
- int n = Character.charCount(text.codePointAt(i));
- char c = text.charAt(i);
- if (c != ' ' && c != '\000') {
- gc.drawString(text.substring(i, i + n), x + i * getCellWidth(), y, true);
- }
- i += n;
- }
- }
-
- /**
- * Cells taken up by the character at index: none for the filler of
- * a wide character, since the character it belongs to already covers it, two
- * for a wide character, one for anything else.
- */
- private static int cellsAt(String text, int index) {
- if (CharWidth.isFiller(text, index)) {
- return 0;
- }
- int codePoint = text.codePointAt(index);
- // a surrogate pair takes two cells whatever its width, as it is stored
- return Character.charCount(codePoint) == 2 || CharWidth.of(codePoint) == 2 ? 2 : 1;
- }
-
- /**
- * The text as it should be handed to a fixed width font: fillers dropped, since
- * the wide character before them already spans their cell, and every other null
- * turned into the space it stands for. What is left lines up column for column,
- * as long as the font draws a wide character in exactly two cells.
- */
- private static String withoutFillers(String text) {
- StringBuilder drawn = new StringBuilder(text.length());
- for (int i = 0; i < text.length(); i++) {
- char c = text.charAt(i);
- if (c != '\000') {
- drawn.append(c);
- } else if (!CharWidth.isFiller(text, i)) {
- drawn.append(' ');
+ gc.fillRectangle(x, y, (end - col) * getCellWidth(), getCellHeight());
+ for (int c = col; c < end; c++) {
+ String drawn = Cells.drawn(data, line, c);
+ if (!drawn.isBlank()) {
+ gc.drawString(drawn, x + (c - col) * getCellWidth(), y, true);
}
}
- return drawn.toString();
}
/**
diff --git a/terminal/bundles/org.eclipse.terminal.control/src/org/eclipse/terminal/model/ITerminalTextData.java b/terminal/bundles/org.eclipse.terminal.control/src/org/eclipse/terminal/model/ITerminalTextData.java
index 46f145a370e..d28f827a03f 100644
--- a/terminal/bundles/org.eclipse.terminal.control/src/org/eclipse/terminal/model/ITerminalTextData.java
+++ b/terminal/bundles/org.eclipse.terminal.control/src/org/eclipse/terminal/model/ITerminalTextData.java
@@ -166,4 +166,12 @@ public interface ITerminalTextData extends ITerminalTextDataReadOnly {
default void clearWrappedLine(int line) {
}
+ /**
+ * Records the whole of a grapheme cluster drawn in the cells from column, see
+ * {@link #getCluster(int, int)}. Writing any of its cells forgets it.
+ * @since 1.2
+ */
+ default void setCluster(int line, int column, String cluster) {
+ }
+
}
diff --git a/terminal/bundles/org.eclipse.terminal.control/src/org/eclipse/terminal/model/ITerminalTextDataReadOnly.java b/terminal/bundles/org.eclipse.terminal.control/src/org/eclipse/terminal/model/ITerminalTextDataReadOnly.java
index 9809e17ef37..b980c6229dc 100644
--- a/terminal/bundles/org.eclipse.terminal.control/src/org/eclipse/terminal/model/ITerminalTextDataReadOnly.java
+++ b/terminal/bundles/org.eclipse.terminal.control/src/org/eclipse/terminal/model/ITerminalTextDataReadOnly.java
@@ -90,4 +90,45 @@ public interface ITerminalTextDataReadOnly {
*/
boolean isWrappedLine(int line);
+ /**
+ * A grapheme cluster is drawn in one or two cells however many characters make
+ * it up: a letter with a mark that has no composed form, an emoji joined to
+ * others, or given a skin tone or a presentation selector. The cells hold the
+ * character drawn for it; this is the whole of it, as the program wrote it.
+ *
+ * @param line
+ * @param column the first of its cells
+ * @return the cluster, or null where the cells hold nothing more than they show
+ * @since 1.2
+ */
+ default String getCluster(int line, int column) {
+ return null;
+ }
+
+ /**
+ * How the characters of a line lie in its cells: a character beyond the BMP, a
+ * wide one and a grapheme cluster may take two. Set when the character is
+ * written, together with what fills its second cell.
+ *
+ * @param line
+ * @param column
+ * @return 2 or 1, the cells the character starting in the cell takes; 0 when the
+ * cell is the second of a character starting in the one before
+ * @since 1.2
+ */
+ default int getCellWidth(int line, int column) {
+ return 1;
+ }
+
+ /**
+ * @param line
+ * @return whether every character on the line is one char in one cell, with no
+ * grapheme cluster kept beside it: then the chars are the text as it is
+ * drawn. False when not known.
+ * @since 1.2
+ */
+ default boolean isSimpleLine(int line) {
+ return false;
+ }
+
}
diff --git a/terminal/tests/org.eclipse.terminal.test/src/org/eclipse/terminal/internal/emulator/AllTestSuite.java b/terminal/tests/org.eclipse.terminal.test/src/org/eclipse/terminal/internal/emulator/AllTestSuite.java
index b82f997997f..7c1083c3d64 100644
--- a/terminal/tests/org.eclipse.terminal.test/src/org/eclipse/terminal/internal/emulator/AllTestSuite.java
+++ b/terminal/tests/org.eclipse.terminal.test/src/org/eclipse/terminal/internal/emulator/AllTestSuite.java
@@ -22,6 +22,7 @@
@Suite
@SelectClasses({ //
VT100EmulatorBackendTest.class, //
+ VT100EmulatorCellsTest.class, //
VT100EmulatorTest.class, //
})
public class AllTestSuite {
diff --git a/terminal/tests/org.eclipse.terminal.test/src/org/eclipse/terminal/internal/emulator/VT100EmulatorBackendTest.java b/terminal/tests/org.eclipse.terminal.test/src/org/eclipse/terminal/internal/emulator/VT100EmulatorBackendTest.java
index 7ac103bf774..68d5e0624f8 100644
--- a/terminal/tests/org.eclipse.terminal.test/src/org/eclipse/terminal/internal/emulator/VT100EmulatorBackendTest.java
+++ b/terminal/tests/org.eclipse.terminal.test/src/org/eclipse/terminal/internal/emulator/VT100EmulatorBackendTest.java
@@ -22,10 +22,16 @@
import static org.eclipse.terminal.model.TerminalColor.WHITE;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertTrue;
+import java.lang.reflect.Method;
+import java.lang.reflect.Modifier;
+import java.util.Set;
+
+import org.eclipse.terminal.internal.model.Cells;
import org.eclipse.terminal.internal.model.TerminalTextData;
import org.eclipse.terminal.internal.model.TerminalTextDataStore;
import org.eclipse.terminal.internal.model.TerminalTextTestHelper;
@@ -1213,10 +1219,12 @@ public void testInsertModeBeyondBmp() {
vt100.appendString("abc");
vt100.setCursorColumn(0);
vt100.setInsertMode(true);
- // U+1D400 is one column wide, but its two chars still take two cells: insert room for both
+ // U+1D400 is one column wide and takes one cell, its two chars kept beside the line
vt100.appendString("\uD835\uDC00");
vt100.setInsertMode(false);
- assertEquals("\uD835\uDC00abc", new String(term.getChars(0), 0, 5));
+ assertEquals("\uD835\uDC00", Cells.text(term, 0, 0));
+ assertEquals(1, term.getCellWidth(0, 0));
+ assertEquals("abc", new String(term.getChars(0), 1, 3));
}
@Test
@@ -1812,4 +1820,393 @@ public void testWrappedLineMarkComesOff() {
vt100.eraseCharacters(1);
assertFalse(term.isWrappedLine(0));
}
+
+ @Test
+ public void testCombiningMarks() {
+ ITerminalTextData term = makeITerminalTextData();
+ IVT100EmulatorBackend vt100 = makeBakend(term);
+ term.setMaxHeight(4);
+ vt100.setDimensions(4, 10);
+ vt100.setCursor(0, 0);
+ // a mark composes with the character before it where the two have one form
+ vt100.appendString("e\u0301x");
+ assertEquals("\u00e9x", new String(term.getChars(0), 0, 2));
+ assertEquals(2, vt100.getCursorColumn());
+ // Hangul conjoining jamo make a syllable, in one wide cell
+ vt100.setCursor(1, 0);
+ vt100.appendString("\u1112\u1161\u11ab|");
+ assertEquals("\ud55c\000|", new String(term.getChars(1), 0, 3));
+ // a mark with no composed form is kept with the character as a cluster
+ vt100.setCursor(2, 0);
+ vt100.appendString("1\ufe0f\u20e3|"); // keycap one
+ assertEquals("1\ufe0f\u20e3", term.getCluster(2, 0));
+ assertEquals('|', term.getChar(2, 2));
+ }
+
+ @Test
+ public void testGraphemeClusters() {
+ ITerminalTextData term = makeITerminalTextData();
+ IVT100EmulatorBackend vt100 = makeBakend(term);
+ term.setMaxHeight(4);
+ vt100.setDimensions(4, 20);
+ String family = "\ud83d\udc68\u200d\ud83d\udc69\u200d\ud83d\udc67"; // man ZWJ woman ZWJ girl
+ String heart = "\u2764\ufe0f"; // heavy black heart + emoji presentation
+ String thumbs = "\ud83d\udc4d\ud83c\udffd"; // thumbs up + medium skin tone
+ String flag = "\ud83c\uddf0\ud83c\uddf7"; // regional indicators K R
+ // a cluster takes two cells however many characters it runs to
+ vt100.setCursor(0, 0);
+ vt100.appendString(family + "|");
+ assertEquals(3, vt100.getCursorColumn());
+ assertEquals("\ud83d\udc68", new String(term.getChars(0), 0, 2)); // the cells hold the first of them
+ assertEquals(family, term.getCluster(0, 0)); // the whole is kept beside them
+ // a narrow character shown as an emoji takes a second cell
+ vt100.setCursor(1, 0);
+ vt100.appendString(heart + "|" + thumbs + "|" + flag + "|");
+ assertEquals(9, vt100.getCursorColumn());
+ assertEquals(heart, term.getCluster(1, 0));
+ assertEquals(thumbs, term.getCluster(1, 3));
+ assertEquals(flag, term.getCluster(1, 6));
+ // writing over a cell forgets the cluster that had it
+ vt100.setCursor(1, 1);
+ vt100.appendString("x");
+ assertNull(term.getCluster(1, 0));
+ assertEquals(' ', term.getChar(1, 0)); // and blanks its other cell, as for any wide character
+ // a cluster on the last two cells, with the wrap pending, is still joined
+ vt100.setVT100LineWrapping(true);
+ vt100.setCursor(2, 18);
+ vt100.appendString(family);
+ assertEquals(family, term.getCluster(2, 18));
+ vt100.appendString("y");
+ assertEquals('y', term.getChar(3, 0));
+ }
+
+ @Test
+ public void testClusterEdits() {
+ ITerminalTextData term = makeITerminalTextData();
+ IVT100EmulatorBackend vt100 = makeBakend(term);
+ term.setMaxHeight(8);
+ vt100.setDimensions(8, 10);
+ String heart = "\u2764\ufe0f";
+ String smile = "\ud83d\ude00";
+ // a mark with no composed form stays on a narrow character, which keeps one cell
+ vt100.setCursor(0, 0);
+ vt100.appendString("q\u0301|1\u20e3|");
+ assertEquals("q\u0301", term.getCluster(0, 0));
+ assertEquals("1\u20e3", term.getCluster(0, 2)); // a keycap without VS16 is narrow, as in Windows Terminal
+ assertEquals("q|1|", new String(term.getChars(0), 0, 4));
+ // a later mark composes with the marks already there: KA, acute, dakuten is drawn as GA;
+ // copied, it is what the program wrote
+ vt100.setCursor(1, 0);
+ vt100.appendString("\u30ab\u0301\u3099");
+ assertEquals('\u30ac', term.getChar(1, 0));
+ assertEquals("\u30ab\u0301\u3099", term.getCluster(1, 0));
+ // a joiner joins only what is written right after it
+ vt100.setCursor(2, 0);
+ vt100.appendString(smile);
+ vt100.setCursor(3, 0);
+ vt100.appendString(smile + "\u200d");
+ vt100.setCursor(2, 2);
+ vt100.appendString("x");
+ assertEquals('x', term.getChar(2, 2));
+ assertNull(term.getCluster(2, 0));
+ // in insert mode the second cell of a heart is made room for
+ vt100.setCursor(4, 0);
+ vt100.appendString("abc");
+ vt100.setCursor(4, 0);
+ vt100.setInsertMode(true);
+ vt100.appendString(heart);
+ vt100.setInsertMode(false);
+ assertEquals("\u2764\000abc", new String(term.getChars(4), 0, 5));
+ assertEquals(heart, term.getCluster(4, 0));
+ // inserting and deleting cells moves clusters with them
+ vt100.setCursor(4, 0);
+ vt100.insertCharacters(1);
+ assertEquals(heart, term.getCluster(4, 1));
+ vt100.deleteCharacters(1);
+ assertEquals(heart, term.getCluster(4, 0));
+ // a heart made wide on the last two cells waits to wrap, as any wide character does
+ vt100.setVT100LineWrapping(true);
+ vt100.setCursor(5, 8);
+ vt100.appendString(heart + "y");
+ assertEquals(heart, term.getCluster(5, 8));
+ assertEquals('y', term.getChar(6, 0));
+ }
+
+ @Test
+ public void testClusterBoundaries() {
+ ITerminalTextData term = makeITerminalTextData();
+ IVT100EmulatorBackend vt100 = makeBakend(term);
+ term.setMaxHeight(8);
+ vt100.setDimensions(8, 20);
+ String smile = "\ud83d\ude00";
+ // UAX #29 GB11: after a joiner only a pictograph goes on, so x is a character of its own
+ vt100.setCursor(0, 0);
+ vt100.appendString(smile + "\u200dx");
+ assertEquals(smile + "\u200d", term.getCluster(0, 0));
+ assertEquals('x', term.getChar(0, 2));
+ // a skin tone goes on with any character, which then takes two cells, as in Windows Terminal
+ vt100.setCursor(1, 0);
+ vt100.appendString("a\ud83c\udffd|");
+ assertEquals("a\ud83c\udffd", term.getCluster(1, 0));
+ assertEquals('|', term.getChar(1, 2));
+ // a third regional indicator starts a flag of its own
+ vt100.setCursor(2, 0);
+ vt100.appendString("\ud83c\uddf0\ud83c\uddf7\ud83c\uddfa|");
+ assertEquals("\ud83c\uddf0\ud83c\uddf7", term.getCluster(2, 0));
+ assertEquals('|', term.getChar(2, 3)); // a regional indicator alone is narrow
+ // an Indic vowel sign goes with its consonant: one cluster, drawn whole over two cells
+ vt100.setCursor(3, 0);
+ vt100.appendString("\u0915\u093f|");
+ assertEquals("\u0915\u093f", term.getCluster(3, 0));
+ assertEquals('|', term.getChar(3, 2));
+ // a selector goes onto the character just written, not onto a cluster before it
+ vt100.setCursor(4, 0);
+ vt100.appendString("q\u0301x\ufe0f|");
+ assertEquals("q\u0301", term.getCluster(4, 0));
+ assertEquals("x\ufe0f", term.getCluster(4, 1));
+ assertEquals('|', term.getChar(4, 3));
+ // a sequence of narrow pictographs with a joiner and a selector is one cluster, two cells
+ vt100.setCursor(5, 0);
+ vt100.appendString("\u26f9\u200d\u2642\ufe0f|");
+ assertEquals("\u26f9\u200d\u2642\ufe0f", term.getCluster(5, 0));
+ assertEquals('|', term.getChar(5, 2));
+ // a character on the last cell that a selector makes wide moves whole to the next line
+ vt100.setVT100LineWrapping(true);
+ vt100.setCursor(6, 19);
+ vt100.appendString("x\ufe0f|");
+ assertEquals('\000', term.getChar(6, 19));
+ assertEquals("x\ufe0f", term.getCluster(7, 0));
+ assertEquals('|', term.getChar(7, 2));
+ // a prefix sign keeps the character after it, which is drawn and copied with it
+ vt100.setCursor(3, 10);
+ vt100.appendString("a\u0600\u4e2d|");
+ assertEquals("\u0600\u4e2d", term.getCluster(3, 11));
+ assertEquals('\u4e2d', term.getChar(3, 11));
+ assertEquals('|', term.getChar(3, 13));
+ // a terminal one column wide has no second cell for a heart to grow into: it keeps one
+ vt100.setDimensions(8, 1);
+ vt100.setCursor(0, 0);
+ vt100.appendString("\u2764\ufe0f");
+ assertEquals("\u2764\ufe0f", term.getCluster(0, 0));
+ assertEquals(1, term.getCellWidth(0, 0));
+ assertEquals('\u2764', term.getChar(0, 0));
+ vt100.setDimensions(8, 20);
+ // a cluster goes on in the next string written
+ vt100.setCursor(6, 0);
+ vt100.appendString(smile);
+ vt100.appendString("\u200d" + smile);
+ assertEquals(smile + "\u200d" + smile, term.getCluster(6, 0));
+ }
+
+ @Test
+ public void testClustersAcrossWritesAndWraps() {
+ ITerminalTextData term = makeITerminalTextData();
+ IVT100EmulatorBackend vt100 = makeBakend(term);
+ term.setMaxHeight(4);
+ vt100.setDimensions(4, 4);
+ // a prefix sign and the character after it, written apart, still make one cluster
+ vt100.setCursor(0, 0);
+ vt100.appendString("\u0600");
+ vt100.appendString("\u4e2d");
+ assertEquals("\u0600\u4e2d", term.getCluster(0, 0));
+ // but not once the cursor has moved, even back to where it was
+ vt100.setCursor(0, 2);
+ vt100.appendString("\u0600");
+ vt100.setCursor(0, 0);
+ vt100.setCursor(0, 2);
+ vt100.appendString("x");
+ assertNull(term.getCluster(0, 2));
+ // with an immediate wrap, a mark after the last cell still goes onto it
+ vt100.setVT100LineWrapping(false);
+ vt100.setCursor(1, 0);
+ vt100.appendString("abce");
+ vt100.appendString("\u0301");
+ assertEquals('\u00e9', term.getChar(1, 3));
+ assertEquals("e\u0301", term.getCluster(1, 3));
+ assertEquals(0, vt100.getCursorColumn());
+ // and a character there made wide moves whole to the line it wrapped to
+ vt100.setCursor(2, 0);
+ vt100.appendString("abc\u2764");
+ vt100.appendString("\ufe0f|");
+ assertEquals('\000', term.getChar(2, 3));
+ assertEquals("\u2764\ufe0f", term.getCluster(3, 0));
+ assertEquals('|', term.getChar(3, 2));
+ }
+
+ @Test
+ public void testClusterGrowingWideScrollsWithIt() {
+ ITerminalTextData term = makeITerminalTextData();
+ IVT100EmulatorBackend vt100 = makeBakend(term);
+ term.setMaxHeight(2);
+ vt100.setDimensions(2, 4);
+ vt100.setVT100LineWrapping(false);
+ // a heart grown wide on the last two cells of the bottom row wraps, which scrolls:
+ // the cluster is written before that, and goes up with its row
+ vt100.setCursor(1, 2);
+ vt100.appendString("\u2764\ufe0f");
+ assertEquals("\u2764\ufe0f", term.getCluster(0, 2));
+ assertNull(term.getCluster(1, 2));
+ }
+
+ @Test
+ public void testClusterEdgeCases() {
+ ITerminalTextData term = makeITerminalTextData();
+ IVT100EmulatorBackend vt100 = makeBakend(term);
+ term.setMaxHeight(4);
+ // a screen one row high: the row a mark goes back to after an immediate wrap is in the history
+ vt100.setDimensions(1, 4);
+ vt100.setVT100LineWrapping(false);
+ vt100.setCursor(0, 0);
+ vt100.appendString("abce");
+ vt100.appendString("\u0301");
+ assertEquals('\u00e9', term.getChar(term.getHeight() - 2, 3));
+ assertEquals("e\u0301", term.getCluster(term.getHeight() - 2, 3));
+ // on a screen one column wide nothing takes two cells, a cluster neither
+ vt100.setVT100LineWrapping(true);
+ vt100.setDimensions(1, 1);
+ vt100.setCursor(0, 0);
+ vt100.appendString("\u4e2d\u0301");
+ assertEquals("\u4e2d\u0301", term.getCluster(term.getHeight() - 1, 0));
+ assertEquals(1, term.getCellWidth(term.getHeight() - 1, 0));
+ // the cells show the character a prefix sign goes before, not the sign
+ vt100.setDimensions(1, 8);
+ vt100.setCursor(0, 0);
+ vt100.appendString("\u0600\u4e2d\u0301");
+ int row = term.getHeight() - 1;
+ assertEquals('\u4e2d', term.getChar(row, 0));
+ assertEquals("\u0600\u4e2d\u0301", term.getCluster(row, 0));
+ // so does a prefix sign beyond the BMP
+ vt100.setCursor(0, 6);
+ vt100.appendString("\ud804\udcbda"); // U+110BD, a prefix that takes no cell
+ assertEquals('a', term.getChar(row, 6));
+ assertEquals("\ud804\udcbda", term.getCluster(row, 6));
+ // a character beyond the BMP that composes into one inside it leaves a filler, not half a pair
+ vt100.setCursor(0, 3);
+ vt100.appendString("\ud87e\udc00\u0301"); // U+2F800, which NFC makes U+4E3D
+ assertEquals('\u4e3d', term.getChar(row, 3));
+ assertEquals('\000', term.getChar(row, 4));
+ assertEquals("\ud87e\udc00\u0301", term.getCluster(row, 3));
+ }
+
+ @Test
+ public void testInsertKeepsAClusterNextToTheMargin() {
+ ITerminalTextData term = makeITerminalTextData();
+ IVT100EmulatorBackend vt100 = makeBakend(term);
+ term.setMaxHeight(2);
+ vt100.setDimensions(2, 10);
+ vt100.setVT100LineWrapping(true);
+ vt100.setCursor(0, 0);
+ vt100.appendString("abcdefx\u0301g\u4e2d");
+ // the wide character is pushed off the end; the accented x moves two cells, its accent with it
+ vt100.setCursor(0, 0);
+ vt100.insertCharacters(2);
+ assertEquals("x\u0301", term.getCluster(0, 8));
+ assertEquals(1, term.getCellWidth(0, 8));
+ assertEquals(1, term.getCellWidth(0, 9));
+ }
+
+ @Test
+ public void testNothingHiddenAndNoEndlessCluster() {
+ ITerminalTextData term = makeITerminalTextData();
+ IVT100EmulatorBackend vt100 = makeBakend(term);
+ term.setMaxHeight(2);
+ vt100.setDimensions(2, 20);
+ // control and format characters no cluster needs are kept out, so a copy shows what is drawn
+ vt100.setCursor(0, 0);
+ vt100.appendString("a\u0085\u202eb\u2066c\u2069\u200b\u200e\udb40\udc41d");
+ assertEquals("abcd", new String(term.getChars(0), 0, 4));
+ for (int col = 0; col < 4; col++) {
+ assertNull(term.getCluster(0, col));
+ }
+ assertEquals(4, vt100.getCursorColumn());
+ // the joiners and the tags of a flag stay
+ String england = "\ud83c\udff4\udb40\udc67\udb40\udc62\udb40\udc65\udb40\udc6e\udb40\udc67\udb40\udc7f";
+ vt100.appendString(england + "\u0628\u200c\u0628");
+ assertEquals(england, term.getCluster(0, 4));
+ assertEquals("\u0628\u200c", term.getCluster(0, 6));
+ // what only hides text: tags past a flag's six, and variation selectors not right after a character
+ vt100.setCursor(1, 0);
+ String tags = "\udb40\udc61".repeat(10);
+ vt100.appendString("\ud83c\udff4" + tags + "\udb40\udc7fx\ufe0f\ufe0f\u200d\ufe0f|");
+ assertEquals("\ud83c\udff4" + "\udb40\udc61".repeat(6) + "\udb40\udc7f", term.getCluster(1, 0));
+ assertEquals("x\ufe0f\u200d", term.getCluster(1, 2));
+ assertEquals('|', term.getChar(1, 4));
+ vt100.setCursor(1, 4);
+ vt100.appendString("y\u180b\u180b\u180ez"); // selectors: one, after a character; U+180E is no selector
+ assertEquals("y\u180b", term.getCluster(1, 4));
+ assertEquals('z', term.getChar(1, 5));
+ vt100.setCursor(1, 4);
+ vt100.appendString("a\u0308\u034f\u0323"); // the joiner keeps marks apart, so it stays
+ assertEquals("a\u0308\u034f\u0323", term.getCluster(1, 4));
+ vt100.setCursor(1, 6);
+ vt100.appendString("\u0600\u0600\u0600" + "1"); // one prefix sign is enough
+ assertEquals("\u06001", term.getCluster(1, 6));
+ // a flood of marks stops growing the cluster
+ vt100.setCursor(1, 0);
+ vt100.appendString("e" + "\u0301".repeat(10000) + "x");
+ assertEquals(32, term.getCluster(1, 0).length());
+ assertEquals('x', term.getChar(1, 1));
+ }
+
+ @Test
+ public void testClusterKeptPastTheMarginAndEndedByAMove() {
+ ITerminalTextData term = makeITerminalTextData();
+ IVT100EmulatorBackend vt100 = makeBakend(term);
+ term.setMaxHeight(2);
+ vt100.setDimensions(2, 4);
+ // a line copied while narrowed keeps the layout of what it holds past the margin
+ vt100.setCursor(0, 0);
+ vt100.appendString("ab\u2764\ufe0f");
+ vt100.setDimensions(2, 2);
+ term.copyLine(term, 0, 0);
+ vt100.setDimensions(2, 4);
+ assertEquals("\u2764\ufe0f", term.getCluster(0, 2));
+ assertEquals(2, term.getCellWidth(0, 2));
+ // moved away and back, the cursor no longer goes on with what it wrote
+ vt100.setCursor(1, 0);
+ vt100.appendString("\u0600");
+ vt100.setCursor(0, 0);
+ vt100.setCursor(1, 1);
+ vt100.appendString("x");
+ assertEquals('x', term.getChar(1, 1));
+ assertEquals(2, vt100.getCursorColumn());
+ }
+
+ /**
+ * Only text can go on with the cluster written last: every other public command,
+ * one added later as well, ends it, and what is written after it is not joined
+ * to it however it left the cursor.
+ */
+ @Test
+ public void testEveryCommandButWritingEndsTheCluster() throws Exception {
+ // settings that change no cell and move no cursor; a resize that changes the
+ // screen ends the cluster itself, one that changes nothing leaves it
+ Set text = Set.of("appendString", "setStyle", "setDefaultStyle", "setInsertMode",
+ "setVT100LineWrapping", "setScrollRegion", "setBufferLineLimit");
+ int checked = 0;
+ for (Method method : VT100EmulatorBackend.class.getDeclaredMethods()) {
+ if (!Modifier.isPublic(method.getModifiers()) || method.getReturnType() != void.class
+ || text.contains(method.getName())) {
+ continue;
+ }
+ Object[] args = new Object[method.getParameterCount()];
+ for (int i = 0; i < args.length; i++) {
+ Class> type = method.getParameterTypes()[i];
+ // a count of one; a buffer, though, holds at least the screen
+ int n = method.getName().equals("setBufferLineLimit") ? 100 : 1;
+ args[i] = type == int.class ? (Object) n : type == boolean.class ? (Object) false : null;
+ }
+ ITerminalTextData term = makeITerminalTextData();
+ VT100EmulatorBackend vt100 = new VT100EmulatorBackend(term);
+ term.setMaxHeight(5);
+ vt100.setDimensions(5, 10);
+ vt100.setCursor(0, 0);
+ vt100.appendString("\u0600"); // a prefix: x right after it would join it
+ method.invoke(vt100, args);
+ vt100.appendString("x");
+ assertNotEquals("\u0600x", Cells.text(term, 0, 0), method.getName());
+ checked++;
+ }
+ assertTrue(checked >= 19, "commands checked: " + checked);
+ }
}
diff --git a/terminal/tests/org.eclipse.terminal.test/src/org/eclipse/terminal/internal/emulator/VT100EmulatorCellsTest.java b/terminal/tests/org.eclipse.terminal.test/src/org/eclipse/terminal/internal/emulator/VT100EmulatorCellsTest.java
new file mode 100644
index 00000000000..421677216b9
--- /dev/null
+++ b/terminal/tests/org.eclipse.terminal.test/src/org/eclipse/terminal/internal/emulator/VT100EmulatorCellsTest.java
@@ -0,0 +1,229 @@
+/*******************************************************************************
+ * Copyright (c) 2026 Contributors to the Eclipse Foundation.
+ * All rights reserved. This program and the accompanying materials
+ * are made available under the terms of the Eclipse Public License 2.0
+ * which accompanies this distribution, and is available at
+ * https://www.eclipse.org/legal/epl-2.0/
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ *******************************************************************************/
+package org.eclipse.terminal.internal.emulator;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.fail;
+
+import java.util.Random;
+
+import org.eclipse.terminal.internal.model.Cells;
+import org.eclipse.terminal.internal.model.TerminalTextDataStore;
+import org.eclipse.terminal.internal.textcanvas.AbstractTextCanvasModel;
+import org.eclipse.terminal.model.ITerminalTextData;
+import org.eclipse.terminal.model.ITerminalTextDataSnapshot;
+import org.eclipse.terminal.model.LineSegment;
+import org.eclipse.terminal.model.TerminalTextDataFactory;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Random writes and edits, checking after every step that the cells hold what
+ * {@link Cells} says they hold: what any path that writes cells has to keep.
+ */
+public class VT100EmulatorCellsTest {
+
+ /** characters that are hard to lay out, and some that are not */
+ private static final String[] PIECES = { "a", "x", " ", "1", "e", "\u4e2d", "\uac00", "\ud83d\ude00",
+ "\ud83d\udc68", "\u200d", "\ufe0f", "\ufe0e", "\u0301", "\u3099", "\u30ab", "\u0600", "\u200b",
+ "\ud83c\uddf0", "\ud83c\uddf7", "\ud83c\udffd", "\u1100", "\u1161", "\u11a8", "\u2764", "\u20e3",
+ "\u0915", "\u093f", "\ud87e\udc00", "\ufa6c", "\u3000" };
+
+ @Test
+ public void testRandomEditsKeepTheCellsConsistent() {
+ Random random = new Random(2904);
+ for (int run = 0; run < 400; run++) {
+ ITerminalTextData term = TerminalTextDataFactory.makeTerminalTextData();
+ VT100EmulatorBackend vt100 = new VT100EmulatorBackend(term);
+ term.setMaxHeight(6);
+ vt100.setDimensions(1 + random.nextInt(4), 1 + random.nextInt(6));
+ vt100.setVT100LineWrapping(random.nextBoolean());
+ StringBuilder log = new StringBuilder();
+ for (int step = 0; step < 60; step++) {
+ String op = step(vt100, random);
+ log.append(op).append("; "); //$NON-NLS-1$
+ check(term, log);
+ }
+ }
+ }
+
+ @Test
+ public void testSplitWritesGiveTheSameCells() {
+ Random random = new Random(29041);
+ for (int run = 0; run < 2000; run++) {
+ StringBuilder text = new StringBuilder();
+ for (int i = random.nextInt(8); i >= 0; i--) {
+ text.append(PIECES[random.nextInt(PIECES.length)]);
+ }
+ int columns = 2 + random.nextInt(6);
+ boolean deferred = random.nextBoolean();
+ String whole = cells(text.toString(), new int[0], columns, deferred);
+ if (text.chars().noneMatch(c -> c == 0x200b || c == 0xfe0e || c == 0xfe0f)
+ && !text.toString().contains("\u0600\u0600")) { //$NON-NLS-1$
+ // what is written comes back whole, but for what is kept out because it only hides text
+ assertEquals(escape(text.toString()), escape(copied(text.toString(), columns, deferred)),
+ "copied back"); //$NON-NLS-1$
+ }
+ int[] splits = random.ints(1 + random.nextInt(3), 0, text.length() + 1).sorted().toArray();
+ assertEquals(whole, cells(text.toString(), splits, columns, deferred),
+ "split at " + java.util.Arrays.toString(splits) + " of " + escape(text.toString())); //$NON-NLS-1$ //$NON-NLS-2$
+ }
+ }
+
+ /** @return all the text, as copying it gives it back, after writing it alone */
+ private static String copied(String text, int columns, boolean deferred) {
+ ITerminalTextData term = TerminalTextDataFactory.makeTerminalTextData();
+ VT100EmulatorBackend vt100 = new VT100EmulatorBackend(term);
+ term.setMaxHeight(20);
+ vt100.setDimensions(20, columns);
+ vt100.setVT100LineWrapping(deferred);
+ vt100.appendString(text);
+ ITerminalTextDataSnapshot snapshot = term.makeSnapshot();
+ snapshot.updateSnapshot(true);
+ String all = new AbstractTextCanvasModel(snapshot) {
+ }.getAllText();
+ return all.substring(0, all.indexOf('\n') < 0 ? all.length() : all.indexOf('\n'));
+ }
+
+ private static String cells(String text, int[] splits, int columns, boolean deferred) {
+ ITerminalTextData term = new TerminalTextDataStore();
+ VT100EmulatorBackend vt100 = new VT100EmulatorBackend(term);
+ term.setMaxHeight(8);
+ vt100.setDimensions(4, columns);
+ vt100.setVT100LineWrapping(deferred);
+ int from = 0;
+ for (int split : splits) {
+ vt100.appendString(text.substring(from, split));
+ from = split;
+ }
+ vt100.appendString(text.substring(from));
+ StringBuilder cells = new StringBuilder();
+ for (int line = 0; line < term.getHeight(); line++) {
+ for (int col = 0; col < columns; col++) {
+ cells.append(term.getCellWidth(line, col)).append(escape(Cells.text(term, line, col))).append('|');
+ }
+ cells.append('\n');
+ }
+ return cells.append(vt100.getCursorLine()).append(',').append(vt100.getCursorColumn()).toString();
+ }
+
+ private static String step(VT100EmulatorBackend vt100, Random random) {
+ int lines = vt100.getLines();
+ int columns = vt100.getColumns();
+ switch (random.nextInt(20)) {
+ case 0:
+ vt100.setCursor(random.nextInt(lines), random.nextInt(columns));
+ return "cup"; //$NON-NLS-1$
+ case 1:
+ vt100.setCursorColumn(0);
+ return "cr"; //$NON-NLS-1$
+ case 2:
+ vt100.processNewline();
+ return "lf"; //$NON-NLS-1$
+ case 3:
+ vt100.eraseCharacters(1 + random.nextInt(3));
+ return "ech"; //$NON-NLS-1$
+ case 4:
+ vt100.insertCharacters(1 + random.nextInt(3));
+ return "ich"; //$NON-NLS-1$
+ case 5:
+ vt100.deleteCharacters(1 + random.nextInt(3));
+ return "dch"; //$NON-NLS-1$
+ case 6:
+ vt100.eraseLineToEnd();
+ return "el0"; //$NON-NLS-1$
+ case 7:
+ vt100.eraseLineToCursor();
+ return "el1"; //$NON-NLS-1$
+ case 8:
+ vt100.insertLines(1);
+ return "il"; //$NON-NLS-1$
+ case 9:
+ vt100.deleteLines(1);
+ return "dl"; //$NON-NLS-1$
+ case 10:
+ vt100.scrollUp(1);
+ return "su"; //$NON-NLS-1$
+ case 11:
+ vt100.scrollDown(1);
+ return "sd"; //$NON-NLS-1$
+ case 12:
+ int newColumns = 1 + random.nextInt(6);
+ vt100.setDimensions(lines, newColumns);
+ return "cols " + newColumns; //$NON-NLS-1$
+ case 13:
+ boolean on = random.nextBoolean();
+ vt100.setInsertMode(on);
+ return "irm " + on; //$NON-NLS-1$
+ case 14:
+ boolean alternate = random.nextBoolean();
+ vt100.enableAlternateScreen(alternate, true);
+ return "alt " + alternate; //$NON-NLS-1$
+ case 15:
+ vt100.processReverseLineFeed();
+ return "ri"; //$NON-NLS-1$
+ default:
+ StringBuilder text = new StringBuilder();
+ for (int i = random.nextInt(4); i >= 0; i--) {
+ text.append(PIECES[random.nextInt(PIECES.length)]);
+ }
+ vt100.appendString(text.toString());
+ return "write " + escape(text.toString()); //$NON-NLS-1$
+ }
+ }
+
+ private static void check(ITerminalTextData term, CharSequence log) {
+ int width = term.getWidth();
+ for (int line = 0; line < term.getHeight(); line++) {
+ for (int col = 0; col < width; col++) {
+ int cells = term.getCellWidth(line, col);
+ if (cells == 2 && (col + 1 >= width || term.getCellWidth(line, col + 1) != 0)) {
+ fail("two cells without a second one at " + line + "," + col + " after " + log); //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$
+ }
+ if (cells == 0 && term.getCluster(line, col) != null) {
+ fail("a cluster on a second cell at " + line + "," + col + " after " + log); //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$
+ }
+ String text = Cells.text(term, line, col);
+ for (int i = 0; i < text.length(); i++) {
+ char c = text.charAt(i);
+ boolean paired = Character.isHighSurrogate(c) ? i + 1 < text.length() && Character.isLowSurrogate(text.charAt(i + 1))
+ : !Character.isLowSurrogate(c) || i > 0 && Character.isHighSurrogate(text.charAt(i - 1));
+ if (!paired) {
+ fail("half a surrogate pair at " + line + "," + col + " after " + log); //$NON-NLS-1$ //$NON-NLS-2$
+ }
+ }
+ }
+ }
+ // selecting, copying and the word under the pointer read the same cells
+ ITerminalTextDataSnapshot snapshot = term.makeSnapshot();
+ snapshot.updateSnapshot(true);
+ AbstractTextCanvasModel model = new AbstractTextCanvasModel(snapshot) {
+ };
+ model.getAllText();
+ for (int line = 0; line < term.getHeight(); line++) {
+ for (LineSegment segment : snapshot.getLineSegments(line, 0, width)) {
+ Cells.drawn(snapshot, line, segment.getColumn(), segment.getText()); // what is drawn
+ }
+ }
+ for (int line = 0; line < term.getHeight(); line++) {
+ for (int col = 0; col < width; col++) {
+ model.setSelection(line, line, col, col);
+ model.expandHoverSelectionAt(-1, -1);
+ model.expandHoverSelectionAt(line, col);
+ }
+ }
+ snapshot.detach();
+ }
+
+ private static String escape(String text) {
+ StringBuilder escaped = new StringBuilder();
+ text.chars().forEach(c -> escaped.append(c < 0x80 ? Character.toString(c) : String.format("\\u%04x", c))); //$NON-NLS-1$
+ return escaped.toString();
+ }
+}
diff --git a/terminal/tests/org.eclipse.terminal.test/src/org/eclipse/terminal/internal/model/AbstractITerminalTextDataTest.java b/terminal/tests/org.eclipse.terminal.test/src/org/eclipse/terminal/internal/model/AbstractITerminalTextDataTest.java
index 457b4ebc943..653370f511a 100644
--- a/terminal/tests/org.eclipse.terminal.test/src/org/eclipse/terminal/internal/model/AbstractITerminalTextDataTest.java
+++ b/terminal/tests/org.eclipse.terminal.test/src/org/eclipse/terminal/internal/model/AbstractITerminalTextDataTest.java
@@ -13,6 +13,7 @@
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -658,4 +659,45 @@ public void testWrappedLines() {
term.clearWrappedLine(2);
assertFalse(term.isWrappedLine(2));
}
+
+ @Test
+ public void testClusters() {
+ ITerminalTextData term = makeITerminalTextData();
+ term.setDimensions(4, 6);
+ term.setMaxHeight(4);
+ fill(term, "abcdef\nghijkl\nmnopqr\nstuvwx");
+ String heart = "\u2764\ufe0f"; // made wide by VS16: two cells
+ assertNull(term.getCluster(1, 2));
+ term.setCluster(1, 2, heart);
+ assertEquals(heart, term.getCluster(1, 2));
+ // writing nothing, even at its second cell, leaves it as it is
+ term.setChars(1, 3, new char[0], null);
+ assertEquals(heart, term.getCluster(1, 2));
+ // writing either of its cells forgets a cluster
+ term.setChar(1, 3, 'X', null);
+ assertNull(term.getCluster(1, 2));
+ term.setCluster(1, 2, heart);
+ term.setChars(1, 0, new char[] { 'y', 'z' }, null);
+ assertEquals(heart, term.getCluster(1, 2)); // untouched cells keep theirs
+ term.setChars(1, 1, new char[] { 'y', 'z' }, null);
+ assertNull(term.getCluster(1, 2));
+ // a narrow character with a mark has one cell: writing the next one keeps it
+ term.setCluster(1, 2, "q\u0301");
+ term.setChar(1, 3, 'X', null);
+ assertEquals("q\u0301", term.getCluster(1, 2));
+ // a cluster goes with its line when lines scroll, and is gone with a cleaned line
+ term.setCluster(2, 0, heart);
+ term.scroll(0, 4, -1);
+ assertEquals(heart, term.getCluster(1, 0));
+ assertNull(term.getCluster(2, 0));
+ term.cleanLine(1);
+ assertNull(term.getCluster(1, 0));
+ // and is copied with its line, also onto itself
+ term.setCluster(0, 4, heart);
+ term.copyLine(term, 0, 0);
+ assertEquals(heart, term.getCluster(0, 4));
+ ITerminalTextData copy = makeITerminalTextData();
+ copy.copy(term);
+ assertEquals(heart, copy.getCluster(0, 4));
+ }
}
diff --git a/terminal/tests/org.eclipse.terminal.test/src/org/eclipse/terminal/internal/model/AllTestSuite.java b/terminal/tests/org.eclipse.terminal.test/src/org/eclipse/terminal/internal/model/AllTestSuite.java
index 236b1b61dc8..02ce35e57af 100644
--- a/terminal/tests/org.eclipse.terminal.test/src/org/eclipse/terminal/internal/model/AllTestSuite.java
+++ b/terminal/tests/org.eclipse.terminal.test/src/org/eclipse/terminal/internal/model/AllTestSuite.java
@@ -21,6 +21,7 @@
*/
@Suite
@SelectClasses({ //
+ CellsTest.class, //
CharWidthTest.class, //
SnapshotChangesTest.class, //
SynchronizedTerminalTextDataTest.class, //
diff --git a/terminal/tests/org.eclipse.terminal.test/src/org/eclipse/terminal/internal/model/CellsTest.java b/terminal/tests/org.eclipse.terminal.test/src/org/eclipse/terminal/internal/model/CellsTest.java
new file mode 100644
index 00000000000..c71bba2c87f
--- /dev/null
+++ b/terminal/tests/org.eclipse.terminal.test/src/org/eclipse/terminal/internal/model/CellsTest.java
@@ -0,0 +1,73 @@
+/*******************************************************************************
+ * Copyright (c) 2026 Contributors to the Eclipse Foundation.
+ * All rights reserved. This program and the accompanying materials
+ * are made available under the terms of the Eclipse Public License 2.0
+ * which accompanies this distribution, and is available at
+ * https://www.eclipse.org/legal/epl-2.0/
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ *******************************************************************************/
+package org.eclipse.terminal.internal.model;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import org.junit.jupiter.api.Test;
+
+public class CellsTest {
+
+ private static TerminalTextDataStore line(String cells, int width) {
+ TerminalTextDataStore data = new TerminalTextDataStore();
+ data.setDimensions(1, width);
+ data.setChars(0, 0, cells.toCharArray(), null);
+ return data;
+ }
+
+ private static String widths(TerminalTextDataStore data) {
+ StringBuilder widths = new StringBuilder();
+ for (int col = 0; col < data.getWidth(); col++) {
+ widths.append(data.getCellWidth(0, col));
+ }
+ return widths.toString();
+ }
+
+ @Test
+ public void testWideCharacters() {
+ // a wide character of the BMP and its filler, a pair beyond it, and empty cells
+ TerminalTextDataStore data = line("\uac00\000\ud83d\ude00a\000", 6);
+ assertEquals("202011", widths(data));
+ assertEquals(0, Cells.start(data, 0, 1));
+ assertEquals(2, Cells.start(data, 0, 3));
+ assertEquals("\uac00", Cells.text(data, 0, 0));
+ assertEquals("", Cells.text(data, 0, 1));
+ assertEquals("\ud83d\ude00", Cells.text(data, 0, 2));
+ assertEquals("", Cells.text(data, 0, 5)); // never written
+ // only the first null after a wide character is its filler
+ assertEquals("2011", widths(line("\uac00\000\000a", 4)));
+ // a lone low surrogate is a character of its own, and so is one cut off by the margin
+ assertEquals("11", widths(line("\ude00a", 2)));
+ assertEquals("11", widths(line("a\uac00", 2)));
+ }
+
+ @Test
+ public void testClusters() {
+ TerminalTextDataStore data = line("\u2764\000e|", 4);
+ data.setCluster(0, 0, "\u2764\ufe0f"); // a heart made wide by VS16
+ data.setCluster(0, 2, "e\u0301"); // an accent with its letter, in one cell
+ assertEquals("2011", widths(data));
+ assertEquals("\u2764\ufe0f", Cells.text(data, 0, 0));
+ assertEquals("\u00e9", Cells.drawn(data, 0, 2));
+ assertEquals("e\u0301", Cells.text(data, 0, 2));
+ // a cluster of two cells needs its second one: written over, it is one cell
+ data.setChars(0, 0, "\u2764x".toCharArray(), null);
+ assertEquals("1111", widths(data));
+ }
+
+ @Test
+ public void testTextOfRange() {
+ TerminalTextDataStore data = line("a\000\uac00\000b\000\000", 7);
+ assertEquals("a \uac00b", Cells.text(data, 0, 0, 7)); // never written: a space, none at the end
+ assertEquals(" \uac00", Cells.text(data, 0, 1, 4)); // the empty cell after a narrow character is a space
+ assertEquals("\uac00", Cells.text(data, 0, 2, 4));
+ assertEquals("b", Cells.text(data, 0, 3, 7)); // a character that starts before the range is not in it
+ }
+}
diff --git a/terminal/tests/org.eclipse.terminal.test/src/org/eclipse/terminal/internal/model/CharWidthTest.java b/terminal/tests/org.eclipse.terminal.test/src/org/eclipse/terminal/internal/model/CharWidthTest.java
index 4602340b863..be0577daf75 100644
--- a/terminal/tests/org.eclipse.terminal.test/src/org/eclipse/terminal/internal/model/CharWidthTest.java
+++ b/terminal/tests/org.eclipse.terminal.test/src/org/eclipse/terminal/internal/model/CharWidthTest.java
@@ -10,8 +10,6 @@
package org.eclipse.terminal.internal.model;
import static org.junit.jupiter.api.Assertions.assertEquals;
-import static org.junit.jupiter.api.Assertions.assertFalse;
-import static org.junit.jupiter.api.Assertions.assertTrue;
import org.junit.jupiter.api.Test;
@@ -57,27 +55,4 @@ public void testOfString() {
assertEquals(1, CharWidth.ofString("eĢ")); // combining mark adds nothing
}
- @Test
- public void testIsFiller() {
- assertTrue(CharWidth.isFiller("ź°\000", 1));
- // a character beyond the BMP covers its two cells with its surrogates,
- // so the null after it is an empty cell, not a filler
- assertFalse(CharWidth.isFiller("š\000", 2));
- assertFalse(CharWidth.isFiller("š\000a", 2));
- assertFalse(CharWidth.isFiller("a\000", 1)); // an empty cell after a narrow character
- assertFalse(CharWidth.isFiller("\000a", 0));
- assertFalse(CharWidth.isFiller("ab", 1));
- assertFalse(CharWidth.isFiller("ź°\000\000", 2)); // only the first null is the filler
- }
-
- @Test
- public void testIsSecondCell() {
- assertTrue(CharWidth.isSecondCell("ź°\000", 1)); // the filler
- assertTrue(CharWidth.isSecondCell("š", 1)); // the low surrogate
- assertFalse(CharWidth.isSecondCell("ź°\000", 0));
- assertFalse(CharWidth.isSecondCell("š", 0));
- assertFalse(CharWidth.isSecondCell("a\000", 1));
- assertFalse(CharWidth.isSecondCell("ab", 1));
- assertFalse(CharWidth.isSecondCell("\uDE00a", 0)); // a lone low surrogate has nothing before it
- }
}
diff --git a/terminal/tests/org.eclipse.terminal.test/src/org/eclipse/terminal/internal/model/TerminalTextDataWindowTest.java b/terminal/tests/org.eclipse.terminal.test/src/org/eclipse/terminal/internal/model/TerminalTextDataWindowTest.java
index b4be30273ad..85f41a84b00 100644
--- a/terminal/tests/org.eclipse.terminal.test/src/org/eclipse/terminal/internal/model/TerminalTextDataWindowTest.java
+++ b/terminal/tests/org.eclipse.terminal.test/src/org/eclipse/terminal/internal/model/TerminalTextDataWindowTest.java
@@ -15,6 +15,7 @@
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -446,4 +447,17 @@ public void testWrappedLines() {
assertFalse(term.isWrappedLine(3));
term.clearWrappedLine(0); // outside window, harmless
}
+
+ @Override
+ @Test
+ public void testClusters() {
+ ITerminalTextData term = makeITerminalTextData();
+ term.setDimensions(4, 6);
+ term.setCluster(0, 2, "\u2764\ufe0f"); // outside window
+ term.setCluster(3, 2, "\u2764\ufe0f");
+ assertNull(term.getCluster(0, 2));
+ assertEquals("\u2764\ufe0f", term.getCluster(3, 2));
+ term.setChar(3, 3, 'X', null);
+ assertNull(term.getCluster(3, 2));
+ }
}
diff --git a/terminal/tests/org.eclipse.terminal.test/src/org/eclipse/terminal/internal/textcanvas/TextCanvasModelTest.java b/terminal/tests/org.eclipse.terminal.test/src/org/eclipse/terminal/internal/textcanvas/TextCanvasModelTest.java
index ecc792c2fa1..e3d5cc7275b 100644
--- a/terminal/tests/org.eclipse.terminal.test/src/org/eclipse/terminal/internal/textcanvas/TextCanvasModelTest.java
+++ b/terminal/tests/org.eclipse.terminal.test/src/org/eclipse/terminal/internal/textcanvas/TextCanvasModelTest.java
@@ -55,6 +55,22 @@ public void testHoverWideSpaceIsBoundary() {
assertEquals(new Point(3, 0), model.getHoverSelectionStart());
}
+ @Test
+ public void testHoverPrefixedSpaceIsBoundary() {
+ TerminalTextData data = new TerminalTextData();
+ data.setDimensions(1, 10);
+ // the space shown in the cell ends a word, whatever prefix was written before it
+ data.setChars(0, 0, "a b".toCharArray(), null);
+ data.setCluster(0, 1, "\u0600 ");
+ ITerminalTextDataSnapshot snapshot = data.makeSnapshot();
+ snapshot.updateSnapshot(true);
+ AbstractTextCanvasModel model = new AbstractTextCanvasModel(snapshot) {
+ };
+ model.expandHoverSelectionAt(0, 2);
+ assertEquals("b", model.getHoverSelectionText());
+ assertEquals(new Point(2, 0), model.getHoverSelectionStart());
+ }
+
@Test
public void testSelectionCoversWideCharactersWhole() {
TerminalTextData data = new TerminalTextData();
@@ -86,4 +102,102 @@ public void testCopyKeepsSpacesBeforeAFold() {
model.setSelection(0, 1, 0, 3);
assertEquals("foo bar", model.getSelectedText());
}
+
+ @Test
+ public void testSelectionCoversWideClusterWhole() {
+ TerminalTextData data = new TerminalTextData();
+ data.setDimensions(1, 10);
+ data.setChars(0, 0, "\u2764\000|".toCharArray(), null);
+ data.setCluster(0, 0, "\u2764\ufe0f"); // a heart made wide by VS16
+ ITerminalTextDataSnapshot snapshot = data.makeSnapshot();
+ snapshot.updateSnapshot(true);
+ AbstractTextCanvasModel model = new AbstractTextCanvasModel(snapshot) {
+ };
+ for (int col : new int[] { 0, 1 }) {
+ model.setSelection(0, 0, col, col);
+ assertEquals("\u2764\ufe0f", model.getSelectedText());
+ }
+ }
+
+ @Test
+ public void testCopiesClusters() {
+ TerminalTextData data = new TerminalTextData();
+ data.setDimensions(1, 12);
+ // a black cat ends in a wide character of the BMP: the empty cells after it stay spaces
+ data.setChars(0, 0, "\ud83d\udc08\000\000x".toCharArray(), null);
+ data.setCluster(0, 0, "\ud83d\udc08\u200d\u2b1b");
+ data.setChars(0, 6, new char[] { '\u00e9' }, null);
+ data.setCluster(0, 6, "e\u0301");
+ ITerminalTextDataSnapshot snapshot = data.makeSnapshot();
+ snapshot.updateSnapshot(true);
+ AbstractTextCanvasModel model = new AbstractTextCanvasModel(snapshot) {
+ };
+ model.setSelection(0, 0, 0, 11);
+ assertEquals("\ud83d\udc08\u200d\u2b1b x e\u0301", model.getSelectedText());
+ assertEquals("\ud83d\udc08\u200d\u2b1b x e\u0301", model.getAllText());
+ }
+
+ @Test
+ public void testCopyKeepsWhatANarrowedScreenHolds() {
+ TerminalTextData data = new TerminalTextData();
+ data.setDimensions(1, 6);
+ data.setChars(0, 0, "abcde\ud55c".toCharArray(), null);
+ data.setDimensions(1, 3);
+ ITerminalTextDataSnapshot snapshot = data.makeSnapshot();
+ snapshot.updateSnapshot(true);
+ AbstractTextCanvasModel model = new AbstractTextCanvasModel(snapshot) {
+ };
+ // the row comes whole, as it does again when the screen widens
+ assertEquals("abcde\ud55c", model.getAllText());
+ model.setSelection(0, 0, 0, 2);
+ assertEquals("abcde\ud55c", model.getSelectedText());
+ // a selection that ends inside the screen ends there
+ model.setSelection(0, 0, 0, 1);
+ assertEquals("ab", model.getSelectedText());
+ // a pair the margin cuts comes whole, though the screen shows no half of it
+ TerminalTextData pair = new TerminalTextData();
+ pair.setDimensions(1, 4);
+ pair.setChars(0, 0, "ab\ud83d\ude00".toCharArray(), null);
+ pair.setDimensions(1, 3);
+ ITerminalTextDataSnapshot narrowed = pair.makeSnapshot();
+ narrowed.updateSnapshot(true);
+ assertEquals("ab\ud83d\ude00", new AbstractTextCanvasModel(narrowed) {
+ }.getAllText());
+ }
+
+ @Test
+ public void testHoverWordKeepsClusters() {
+ TerminalTextData data = new TerminalTextData();
+ data.setDimensions(1, 8);
+ data.setChars(0, 0, "a\u2764\000b c".toCharArray(), null);
+ data.setCluster(0, 1, "\u2764\ufe0f"); // a heart made wide by VS16
+ ITerminalTextDataSnapshot snapshot = data.makeSnapshot();
+ snapshot.updateSnapshot(true);
+ AbstractTextCanvasModel model = new AbstractTextCanvasModel(snapshot) {
+ };
+ model.expandHoverSelectionAt(0, 0);
+ assertEquals("a\u2764\ufe0fb", model.getHoverSelectionText());
+ }
+
+ @Test
+ public void testHoverAfterResizeAndWideClusters() {
+ TerminalTextData data = new TerminalTextData();
+ data.setDimensions(1, 5);
+ data.setChars(0, 0, "abcde".toCharArray(), null);
+ data.setDimensions(1, 4); // the row keeps all five characters
+ ITerminalTextDataSnapshot snapshot = data.makeSnapshot();
+ snapshot.updateSnapshot(true);
+ AbstractTextCanvasModel model = new AbstractTextCanvasModel(snapshot) {
+ };
+ model.expandHoverSelectionAt(0, 1);
+ assertEquals("abcd", model.getHoverSelectionText());
+ // the empty cell after a wide character with a mark is still a gap
+ data.setDimensions(1, 6);
+ data.setChars(0, 0, "\u4e2d\000\000x".toCharArray(), null);
+ data.setCluster(0, 0, "\u4e2d\u0301");
+ snapshot.updateSnapshot(true);
+ model.expandHoverSelectionAt(-1, -1); // let go of the last hover first
+ model.expandHoverSelectionAt(0, 0);
+ assertEquals("\u4e2d\u0301", model.getHoverSelectionText());
+ }
}