From 94faeaf8e1e521e3edef1434aa3917b42f5d8bd7 Mon Sep 17 00:00:00 2001 From: insjang Date: Wed, 2 Sep 2026 21:25:24 +0900 Subject: [PATCH] Terminal: count East Asian Wide characters as two columns The emulator assumed every character occupies one cell, so Hangul, Han and Kana text, fullwidth forms and emoji were placed one column short per character and the screen fell apart as soon as a program laid text out for a real terminal (line editors, curses UIs, Ink based CLIs). Add CharWidth, a UAX #11 East Asian Width lookup: Wide and Fullwidth count as two columns, combining marks and controls as zero, Ambiguous as one, as UAX #11 recommends outside an East Asian legacy context. The emulator advances the cursor by that width and stores a NUL filler in the second cell of a wide character, never splits one across the right margin, blanks the other half when either half is overwritten and counts insert mode in cells. The renderer skips the fillers so a fixed width font draws a wide glyph over both cells, falls back to placing each character at its own cell when the font does not advance exactly one cell per column, and draws a character beyond the BMP whole. A partial repaint that starts on the second cell of a wide character is widened to its first, and copying drops the fillers. Tests cover the width table, placement, the margin, overwriting halves and insert mode. --- .../META-INF/MANIFEST.MF | 2 +- .../emulator/VT100EmulatorBackend.java | 90 ++++++++++++- .../terminal/internal/model/CharWidth.java | 122 ++++++++++++++++++ .../textcanvas/AbstractTextCanvasModel.java | 14 +- .../internal/textcanvas/TextCanvas.java | 25 ++++ .../internal/textcanvas/TextLineRenderer.java | 73 ++++++++++- .../emulator/VT100EmulatorBackendTest.java | 89 +++++++++++++ .../terminal/internal/model/AllTestSuite.java | 1 + .../internal/model/CharWidthTest.java | 69 ++++++++++ 9 files changed, 473 insertions(+), 12 deletions(-) create mode 100644 terminal/bundles/org.eclipse.terminal.control/src/org/eclipse/terminal/internal/model/CharWidth.java create mode 100644 terminal/tests/org.eclipse.terminal.test/src/org/eclipse/terminal/internal/model/CharWidthTest.java diff --git a/terminal/bundles/org.eclipse.terminal.control/META-INF/MANIFEST.MF b/terminal/bundles/org.eclipse.terminal.control/META-INF/MANIFEST.MF index ff5a3fb72be..0c618bbd154 100644 --- a/terminal/bundles/org.eclipse.terminal.control/META-INF/MANIFEST.MF +++ b/terminal/bundles/org.eclipse.terminal.control/META-INF/MANIFEST.MF @@ -2,7 +2,7 @@ Manifest-Version: 1.0 Bundle-ManifestVersion: 2 Bundle-Name: %pluginName Bundle-SymbolicName: org.eclipse.terminal.control; singleton:=true -Bundle-Version: 1.1.200.qualifier +Bundle-Version: 1.1.300.qualifier Bundle-Activator: org.eclipse.terminal.internal.control.impl.TerminalPlugin Bundle-Vendor: %providerName Bundle-Localization: plugin 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 4dfc1f1b560..e4e13e270b5 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,7 @@ *******************************************************************************/ package org.eclipse.terminal.internal.emulator; +import org.eclipse.terminal.internal.model.CharWidth; import org.eclipse.terminal.model.ITerminalTextData; import org.eclipse.terminal.model.TerminalStyle; @@ -311,7 +312,7 @@ public void appendString(String buffer) { synchronized (fTerminal) { char[] chars = buffer.toCharArray(); if (fInsertMode) { - insertCharacters(chars.length); + insertCharacters(CharWidth.ofString(buffer)); // room in cells, not characters } int line = toAbsoluteLine(fCursorLine); int i = 0; @@ -319,10 +320,50 @@ public void appendString(String buffer) { if (fWrapPending) { line = doLineWrap(); } - int n = Math.min(fColumns - fCursorColumn, chars.length - i); - fTerminal.setChars(line, fCursorColumn, chars, i, n, fStyle); - int col = fCursorColumn + n; - i += n; + int room = fColumns - fCursorColumn; + int col; + int n = narrowRun(chars, i, room); + if (n > 0) { + breakWideChar(line, fCursorColumn); + breakWideChar(line, fCursorColumn + n - 1); + fTerminal.setChars(line, fCursorColumn, chars, i, n, fStyle); + col = fCursorColumn + n; + i += n; + } else { + int codePoint = Character.codePointAt(chars, i); + int charsUsed = Character.charCount(codePoint); + int width = CharWidth.of(codePoint); + if (width == 0) { + // combining marks and other non-printing code points occupy no cell + i += charsUsed; + continue; + } + // a surrogate pair cannot share a cell, so it always takes two + if (charsUsed == 2) { + width = 2; + } + if (width > room) { + if (fCursorColumn > 0) { + // a wide character is never split across the right margin + line = doLineWrap(); + continue; + } + // terminal narrower than the character itself + width = room; + } + breakWideChar(line, fCursorColumn); + breakWideChar(line, fCursorColumn + width - 1); + 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; + } // wrap needed? if (col == fColumns) { if (fVT100LineWrapping) { @@ -339,6 +380,45 @@ public void appendString(String buffer) { } } + /** + * 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. + */ + private void breakWideChar(int line, int col) { + if (col < 0 || col >= fColumns) { + return; + } + char c = fTerminal.getChar(line, col); + if (c == '\000') { + if (col > 0 && CharWidth.of(fTerminal.getChar(line, col - 1)) == 2) { + blank(line, col - 1); + } + } else if (CharWidth.of(c) == 2 && col + 1 < fColumns && fTerminal.getChar(line, col + 1) == '\000') { + blank(line, col + 1); + } + } + + private void blank(int line, int col) { + fTerminal.setChar(line, col, ' ', fTerminal.getStyle(line, 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. + */ + 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++; + } + return n; + } + private int doLineWrap() { int line; line = toAbsoluteLine(fCursorLine); 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 new file mode 100644 index 00000000000..6966ba67a11 --- /dev/null +++ b/terminal/bundles/org.eclipse.terminal.control/src/org/eclipse/terminal/internal/model/CharWidth.java @@ -0,0 +1,122 @@ +/******************************************************************************* + * 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; + +/** + * Display width of a code point, following Unicode Standard Annex #11 + * (East Asian Width). Used by the emulator to keep its column arithmetic in + * step with what a terminal application assumes. + *

+ * East Asian Wide (W) and Fullwidth (F) count as two columns. Ambiguous (A) is + * treated as narrow, as UAX #11 recommends for a context with no East Asian + * legacy encoding. Combining marks and non-printing characters count as zero. + */ +public final class CharWidth { + + private CharWidth() { + } + + /** + * Wide and Fullwidth ranges from EastAsianWidth-17.0.0.txt, as inclusive + * [start, end] pairs in ascending order. Everything not listed here defaults + * to Narrow, which is what the file's {@code @missing} line specifies. + */ + private static final int[] WIDE_RANGES = { + 0x01100, 0x0115F, 0x0231A, 0x0231B, 0x02329, 0x0232A, 0x023E9, 0x023EC, + 0x023F0, 0x023F0, 0x023F3, 0x023F3, 0x025FD, 0x025FE, 0x02614, 0x02615, + 0x02630, 0x02637, 0x02648, 0x02653, 0x0267F, 0x0267F, 0x0268A, 0x0268F, + 0x02693, 0x02693, 0x026A1, 0x026A1, 0x026AA, 0x026AB, 0x026BD, 0x026BE, + 0x026C4, 0x026C5, 0x026CE, 0x026CE, 0x026D4, 0x026D4, 0x026EA, 0x026EA, + 0x026F2, 0x026F3, 0x026F5, 0x026F5, 0x026FA, 0x026FA, 0x026FD, 0x026FD, + 0x02705, 0x02705, 0x0270A, 0x0270B, 0x02728, 0x02728, 0x0274C, 0x0274C, + 0x0274E, 0x0274E, 0x02753, 0x02755, 0x02757, 0x02757, 0x02795, 0x02797, + 0x027B0, 0x027B0, 0x027BF, 0x027BF, 0x02B1B, 0x02B1C, 0x02B50, 0x02B50, + 0x02B55, 0x02B55, 0x02E80, 0x02E99, 0x02E9B, 0x02EF3, 0x02F00, 0x02FD5, + 0x02FF0, 0x0303E, 0x03041, 0x03096, 0x03099, 0x030FF, 0x03105, 0x0312F, + 0x03131, 0x0318E, 0x03190, 0x031E5, 0x031EF, 0x0321E, 0x03220, 0x03247, + 0x03250, 0x0A48C, 0x0A490, 0x0A4C6, 0x0A960, 0x0A97C, 0x0AC00, 0x0D7A3, + 0x0F900, 0x0FAFF, 0x0FE10, 0x0FE19, 0x0FE30, 0x0FE52, 0x0FE54, 0x0FE66, + 0x0FE68, 0x0FE6B, 0x0FF01, 0x0FF60, 0x0FFE0, 0x0FFE6, 0x16FE0, 0x16FE4, + 0x16FF0, 0x16FF6, 0x17000, 0x18CD5, 0x18CFF, 0x18D1E, 0x18D80, 0x18DF2, + 0x1AFF0, 0x1AFF3, 0x1AFF5, 0x1AFFB, 0x1AFFD, 0x1AFFE, 0x1B000, 0x1B122, + 0x1B132, 0x1B132, 0x1B150, 0x1B152, 0x1B155, 0x1B155, 0x1B164, 0x1B167, + 0x1B170, 0x1B2FB, 0x1D300, 0x1D356, 0x1D360, 0x1D376, 0x1F004, 0x1F004, + 0x1F0CF, 0x1F0CF, 0x1F18E, 0x1F18E, 0x1F191, 0x1F19A, 0x1F200, 0x1F202, + 0x1F210, 0x1F23B, 0x1F240, 0x1F248, 0x1F250, 0x1F251, 0x1F260, 0x1F265, + 0x1F300, 0x1F320, 0x1F32D, 0x1F335, 0x1F337, 0x1F37C, 0x1F37E, 0x1F393, + 0x1F3A0, 0x1F3CA, 0x1F3CF, 0x1F3D3, 0x1F3E0, 0x1F3F0, 0x1F3F4, 0x1F3F4, + 0x1F3F8, 0x1F43E, 0x1F440, 0x1F440, 0x1F442, 0x1F4FC, 0x1F4FF, 0x1F53D, + 0x1F54B, 0x1F54E, 0x1F550, 0x1F567, 0x1F57A, 0x1F57A, 0x1F595, 0x1F596, + 0x1F5A4, 0x1F5A4, 0x1F5FB, 0x1F64F, 0x1F680, 0x1F6C5, 0x1F6CC, 0x1F6CC, + 0x1F6D0, 0x1F6D2, 0x1F6D5, 0x1F6D8, 0x1F6DC, 0x1F6DF, 0x1F6EB, 0x1F6EC, + 0x1F6F4, 0x1F6FC, 0x1F7E0, 0x1F7EB, 0x1F7F0, 0x1F7F0, 0x1F90C, 0x1F93A, + 0x1F93C, 0x1F945, 0x1F947, 0x1F9FF, 0x1FA70, 0x1FA7C, 0x1FA80, 0x1FA8A, + 0x1FA8E, 0x1FAC6, 0x1FAC8, 0x1FAC8, 0x1FACD, 0x1FADC, 0x1FADF, 0x1FAEA, + 0x1FAEF, 0x1FAF8, 0x20000, 0x2FFFD, 0x30000, 0x3FFFD + }; + + /** @return 0 for combining and non-printing, 2 for East Asian W/F, else 1 */ + public static int of(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(); + } + + /** + * 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. + * + * @return whether the cell at {@code index} is the filler of the character + * before it + */ + public static boolean isFiller(CharSequence text, int index) { + return text.charAt(index) == '\000' && index > 0 && of(Character.codePointBefore(text, index)) == 2; + } + + private static boolean isZeroWidth(int codePoint) { + // Hangul conjoining jamo vowels and trailing consonants: EAW lists them as + // neutral, but they combine into the leading consonant before them. + if (codePoint >= 0x1160 && codePoint <= 0x11FF) { + return true; + } + switch (Character.getType(codePoint)) { + case Character.NON_SPACING_MARK: + case Character.ENCLOSING_MARK: + case Character.CONTROL: + case Character.FORMAT: + return true; + default: + return false; + } + } + + private static boolean isWide(int codePoint) { + int lo = 0, hi = WIDE_RANGES.length / 2 - 1; + while (lo <= hi) { + int mid = (lo + hi) >>> 1, i = mid * 2; + if (codePoint < WIDE_RANGES[i]) { + hi = mid - 1; + } else if (codePoint > WIDE_RANGES[i + 1]) + lo = mid + 1; + else { + return true; + } + } + return false; + } +} 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 db57118ddc5..09acdc6d71a 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,6 +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.model.ITerminalTextDataReadOnly; import org.eclipse.terminal.model.ITerminalTextDataSnapshot; import org.eclipse.terminal.model.TextRange; @@ -427,8 +428,17 @@ private static String scrubLine(String text) { } text = text.substring(0, i + 1); // - // null means space - return text.replace('\000', ' '); + // 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(' '); + } + } + return scrubbed.toString(); } /** 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 92f924be1a7..9d941c9099d 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,6 +43,7 @@ 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; @@ -535,9 +536,33 @@ protected void repaintRange(int col, int line, int width, int height) { @Override protected void drawLine(GC gc, int line, int x, int y, int colFirst, int colLast) { + // A wide character spans two cells and has to be drawn from the first of + // them. When the damaged area starts on the second one, widen the range so + // the glyph is drawn from its own origin. Clipping then keeps only the half + // that was actually damaged, which is the half that needed repainting. + if (isFillerCell(line, colFirst)) { + colFirst--; + x -= getCellWidth(); + } + // Same at the other end: a wide character starting in the last cell of the + // range would be cut in half by the edge of the range. + if (isFillerCell(line, colLast)) { + colLast++; + } fCellRenderer.drawLine(fCellCanvasModel, gc, line, x, y, colFirst, colLast); } + /** + * @return whether the cell holds the second half of a wide character + */ + private boolean isFillerCell(int line, int col) { + ITerminalTextDataReadOnly text = fCellCanvasModel.getTerminalText(); + if (col <= 0 || col >= text.getWidth() || line < 0 || line >= text.getHeight()) { + return false; + } + return text.getChar(line, col) == '\000' && CharWidth.of(text.getChar(line, col - 1)) == 2; + } + @Override protected Color getTerminalBackgroundColor(Device device) { return fCellRenderer.getDefaultBackgroundColor(); 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 8df74b82b22..a225b8486d2 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 @@ -26,6 +26,7 @@ import org.eclipse.swt.graphics.GC; import org.eclipse.swt.graphics.Point; import org.eclipse.terminal.connector.Logger; +import org.eclipse.terminal.internal.model.CharWidth; import org.eclipse.terminal.model.ITerminalTextDataReadOnly; import org.eclipse.terminal.model.LineSegment; import org.eclipse.terminal.model.TerminalColor; @@ -159,19 +160,83 @@ private void drawText(GC gc, int x, int y, int colFirst, int col, String text) { // 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 xx = x + offset + i * fStyleMap.getFontWidth(); + int cells = cellsAt(text, i); // TODO why do I have to draw the background character by character?????? - gc.fillRectangle(xx, y, fStyleMap.getFontWidth(), fStyleMap.getFontHeight()); + gc.fillRectangle(xx, y, cells * fStyleMap.getFontWidth(), fStyleMap.getFontHeight()); if (c != ' ' && c != '\000') { gc.drawString(String.valueOf(c), fStyleMap.getCharOffset(c) + xx, y, false); } + xx += cells * fStyleMap.getFontWidth(); } } else { - text = text.replace('\000', ' '); - gc.drawString(text, x + offset, y, false); + // 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); + } + } + } + + /** + * 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. + */ + private void drawCellByCell(GC gc, int x, int y, String text) { + // 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(' '); + } } + return drawn.toString(); } /** 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 aecbc640616..c53ea81f425 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 @@ -1086,4 +1086,93 @@ public void testNewlineInTopAnchoredScrollRegionCurrentlyDiscardsTopLine() { assertNull(term.getChars(3)); assertEquals("4444", new String(term.getChars(4))); // footer below the region is untouched } + + @Test + public void testAppendStringWide() { + ITerminalTextData term = makeITerminalTextData(); + IVT100EmulatorBackend vt100 = makeBakend(term); + term.setMaxHeight(4); + vt100.setDimensions(4, 10); + vt100.setCursor(0, 0); + // a wide character takes two cells, the second holding a null filler + vt100.appendString("한글"); + assertEqualsTerm("한 글 \n" + " \n" + " \n" + " ", toMultiLineText(term)); + assertEquals(4, vt100.getCursorColumn()); + vt100.setCursor(1, 0); + vt100.appendString("a한b"); + assertEqualsTerm("한 글 \n" + "a한 b \n" + " \n" + " ", toMultiLineText(term)); + assertEquals(4, vt100.getCursorColumn()); + // a character beyond the BMP is two chars in two cells + vt100.setCursor(2, 0); + vt100.appendString("a😀b"); + assertEquals(4, vt100.getCursorColumn()); + assertEquals("a😀b", new String(term.getChars(2), 0, 4)); + // a combining mark takes no cell + vt100.setCursor(3, 0); + vt100.appendString("e\u0301x"); + assertEquals(2, vt100.getCursorColumn()); + } + + @Test + public void testAppendStringWideAtMargin() { + ITerminalTextData term = makeITerminalTextData(); + IVT100EmulatorBackend vt100 = makeBakend(term); + term.setMaxHeight(4); + vt100.setDimensions(4, 5); + vt100.setCursor(0, 0); + vt100.appendString("abc한"); + assertEqualsTerm("abc한 \n" + " \n" + " \n" + " ", toMultiLineText(term)); + // a wide character is never split across the margin: it goes to the next line whole + vt100.setCursor(1, 0); + vt100.appendString("abcd한"); + assertEqualsTerm("abc한 \n" + "abcd \n" + "한 \n" + " ", toMultiLineText(term)); + assertTrue(term.isWrappedLine(1)); + assertEquals(2, vt100.getCursorLine()); + assertEquals(2, vt100.getCursorColumn()); + } + + @Test + public void testOverwriteHalfOfWide() { + ITerminalTextData term = makeITerminalTextData(); + IVT100EmulatorBackend vt100 = makeBakend(term); + term.setMaxHeight(4); + vt100.setDimensions(4, 10); + // writing over the glyph leaves its filler blank, and over the filler leaves the glyph blank + vt100.setCursor(0, 0); + vt100.appendString("한글"); + vt100.setCursor(0, 0); + vt100.appendString("x"); + assertEquals("x 글\000", new String(term.getChars(0), 0, 4)); + vt100.setCursor(1, 0); + vt100.appendString("한글"); + vt100.setCursor(1, 1); + vt100.appendString("x"); + assertEquals(" x글\000", new String(term.getChars(1), 0, 4)); + vt100.setCursor(2, 0); + vt100.appendString("한글"); + vt100.setCursor(2, 3); + vt100.appendString("나"); + assertEquals("한\000 나\000", new String(term.getChars(2), 0, 5)); + // narrow over narrow is untouched by any of this + vt100.setCursor(3, 0); + vt100.appendString("abcd"); + vt100.setCursor(3, 1); + vt100.appendString("XY"); + assertEquals("aXYd", new String(term.getChars(3), 0, 4)); + } + + @Test + public void testInsertModeWide() { + ITerminalTextData term = makeITerminalTextData(); + IVT100EmulatorBackend vt100 = makeBakend(term); + term.setMaxHeight(1); + vt100.setDimensions(1, 10); + vt100.setCursor(0, 0); + vt100.appendString("abcdef"); + vt100.setCursorColumn(1); + vt100.setInsertMode(true); + vt100.appendString("한"); + // pushes the rest along by two cells, not one + assertEquals("a한\000bcdef", new String(term.getChars(0), 0, 8)); + } } 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 47660269f0a..236b1b61dc8 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({ // + CharWidthTest.class, // SnapshotChangesTest.class, // SynchronizedTerminalTextDataTest.class, // TerminalTextDataFastScrollTest.class, // 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 new file mode 100644 index 00000000000..dfe94127a8d --- /dev/null +++ b/terminal/tests/org.eclipse.terminal.test/src/org/eclipse/terminal/internal/model/CharWidthTest.java @@ -0,0 +1,69 @@ +/******************************************************************************* + * 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 static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +public class CharWidthTest { + + @Test + public void testNarrow() { + assertEquals(1, CharWidth.of('A')); + assertEquals(1, CharWidth.of(0x00E9)); // e with acute + assertEquals(1, CharWidth.of(0x00B1)); // ambiguous: plus-minus + assertEquals(1, CharWidth.of(0x2500)); // ambiguous: box drawing + assertEquals(1, CharWidth.of(0xFFFD)); // ambiguous: replacement character + } + + @Test + public void testWide() { + assertEquals(2, CharWidth.of(0xAC00)); // hangul syllable + assertEquals(2, CharWidth.of(0x6F22)); // han ideograph + assertEquals(2, CharWidth.of(0xFF21)); // fullwidth A + assertEquals(2, CharWidth.of(0x1100)); // hangul jamo, leading consonant + assertEquals(2, CharWidth.of(0x3131)); // hangul compatibility jamo + assertEquals(2, CharWidth.of(0x3000)); // ideographic space + assertEquals(2, CharWidth.of(0x1F600)); // emoji + } + + @Test + public void testZeroWidth() { + assertEquals(0, CharWidth.of(0x0301)); // combining acute + assertEquals(0, CharWidth.of(0x200B)); // zero width space + assertEquals(0, CharWidth.of(0x1161)); // hangul jamo vowel, combines with the consonant before it + assertEquals(0, CharWidth.of('\n')); + assertEquals(0, CharWidth.of(0)); + assertEquals(0, CharWidth.of(0x7F)); + assertEquals(0, CharWidth.of(0x85)); // C1 control + } + + @Test + public void testOfString() { + assertEquals(0, CharWidth.ofString("")); + assertEquals(3, CharWidth.ofString("abc")); + assertEquals(7, CharWidth.ofString("한글abc")); // two hangul syllables, three letters + assertEquals(4, CharWidth.ofString("a😀b")); // surrogate pair counts once, as two cells + assertEquals(1, CharWidth.ofString("é")); // combining mark adds nothing + } + + @Test + public void testIsFiller() { + assertTrue(CharWidth.isFiller("가\000", 1)); + assertTrue(CharWidth.isFiller("😀\000", 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 + } +}