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("é")); // 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()); + } }