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..8ef0e9347c0 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.2.0.qualifier
Bundle-Activator: org.eclipse.terminal.internal.control.impl.TerminalPlugin
Bundle-Vendor: %providerName
Bundle-Localization: plugin
@@ -30,5 +30,5 @@ Export-Package: org.eclipse.terminal.connector;version="1.0.100";
org.eclipse.terminal.internal.model;x-internal:=true,
org.eclipse.terminal.internal.preferences;x-internal:=true;x-friends:="org.eclipse.terminal.view.ui",
org.eclipse.terminal.internal.textcanvas;x-internal:=true,
- org.eclipse.terminal.model;version="1.0.100";uses:="org.eclipse.swt.graphics"
+ org.eclipse.terminal.model;version="1.1.0";uses:="org.eclipse.swt.graphics"
Automatic-Module-Name: org.eclipse.terminal.control
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..96720f1cc37 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;
@@ -216,6 +217,8 @@ public void eraseLineToEnd() {
for (int col = fCursorColumn; col < fColumns; col++) {
fTerminal.setChar(line, col, '\000', null);
}
+ // Nothing is left out to the margin, so the line ends where the cursor is.
+ fTerminal.clearWrappedLine(line);
}
}
@@ -311,7 +314,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 +322,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) {
@@ -333,12 +376,56 @@ public void appendString(String buffer) {
line = doLineWrap();
}
} else {
+ // Writing that stops short of the margin says the line ends here.
+ // A program that draws its own screen writes the same row again and
+ // again, and a row that was folded in one frame is a row of its own
+ // in the next, so the mark has to be able to come off.
+ fTerminal.clearWrappedLine(line);
setCursorColumn(col);
}
}
}
}
+ /**
+ * 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/model/SynchronizedTerminalTextData.java b/terminal/bundles/org.eclipse.terminal.control/src/org/eclipse/terminal/internal/model/SynchronizedTerminalTextData.java
index a142ef8578a..3de26a78cb4 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
@@ -157,4 +157,9 @@ synchronized public boolean isWrappedLine(int line) {
synchronized public void setWrappedLine(int line) {
fData.setWrappedLine(line);
}
+
+ @Override
+ synchronized public void clearWrappedLine(int line) {
+ fData.clearWrappedLine(line);
+ }
}
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 baa3b620df8..d24465d1b3a 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
@@ -334,4 +334,9 @@ public boolean isWrappedLine(int line) {
public void setWrappedLine(int line) {
fData.setWrappedLine(line);
}
+
+ @Override
+ public void clearWrappedLine(int line) {
+ fData.clearWrappedLine(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 c76fab20863..db68f309069 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
@@ -308,4 +308,10 @@ public void setWrappedLine(int line) {
fData.setWrappedLine(getPositionOfLine(line));
}
+ @Override
+ public void clearWrappedLine(int line) {
+ validateLineParameter(line);
+ fData.clearWrappedLine(getPositionOfLine(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 04763621546..d0f3ac2b398 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
@@ -394,4 +394,9 @@ public boolean isWrappedLine(int line) {
public void setWrappedLine(int line) {
fWrappedLines.set(line);
}
+
+ @Override
+ public void clearWrappedLine(int line) {
+ fWrappedLines.clear(line);
+ }
}
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 21e343ee6b8..92149232a88 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
@@ -267,4 +267,11 @@ public void setWrappedLine(int line) {
fData.setWrappedLine(line - fWindowStartLine);
}
}
+
+ @Override
+ public void clearWrappedLine(int line) {
+ if (isInWindow(line)) {
+ fData.clearWrappedLine(line - fWindowStartLine);
+ }
+ }
}
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..b0ea9837688 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();
}
/**
@@ -455,7 +465,11 @@ private String extractSelectedText() {
} else {
text = ""; //$NON-NLS-1$
}
- buffer.append(text);
+ // Cells past the last character were never written to. They read as spaces
+ // because that is how an empty cell is drawn, but there is no text there to
+ // copy: on a line that ran on to the next one they are the room the fold
+ // left, and on one that ended they are the rest of the screen.
+ buffer.append(text.stripTrailing());
if (line < fSeletionEndLine && !fSelectionSnapshot.isWrappedLine(line)) {
buffer.append('\n');
}
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..0af2a1d194a 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;
@@ -57,6 +58,10 @@ public class TextCanvas extends GridCanvas {
private final ILinelRenderer fCellRenderer;
private boolean fScrollLock;
private Point fDraggingStart;
+ /** -1 above the top, 1 below the bottom, 0 while the pointer is inside */
+ private int fDragPast;
+ private boolean fDragScrolling;
+ private static final int DRAG_SCROLL_INTERVAL = 60;
private Point fDraggingEnd;
private boolean fHasSelection;
private ResizeListener fResizeListener;
@@ -269,6 +274,12 @@ public void mouseUp(MouseEvent e) {
});
addMouseMoveListener(e -> {
if (fDraggingStart != null) {
+ // Dragging past an edge keeps the lines beyond it coming into view.
+ fDragPast = e.y < 0 ? -1 : e.y >= getClientArea().height ? 1 : 0;
+ if (fDragPast != 0 && !fDragScrolling) {
+ fDragScrolling = true;
+ getDisplay().timerExec(DRAG_SCROLL_INTERVAL, this::dragScroll);
+ }
Point curr = screenPointToCell(e.x, e.y);
updateHasSelection(e);
switch (fSelMode) {
@@ -297,6 +308,18 @@ public void mouseUp(MouseEvent e) {
setHorizontalBarVisible(false);
}
+ private void dragScroll() {
+ if (isDisposed() || fDraggingStart == null || fDragPast == 0) {
+ fDragScrolling = false;
+ return;
+ }
+ scrollYDelta(fDragPast * getCellHeight());
+ Point p = toControl(getDisplay().getCursorLocation());
+ setSelection(screenPointToCell(p.x, p.y));
+ redraw();
+ getDisplay().timerExec(DRAG_SCROLL_INTERVAL, this::dragScroll);
+ }
+
private static class Range {
final Point start;
final Point end;
@@ -494,6 +517,11 @@ private void calculateGrid() {
} finally {
setRedraw(true);
}
+ // NO_REDRAW_RESIZE paints only what a resize uncovers. When the grid gets
+ // narrower, what was drawn in the columns now past its edge stays there.
+ if (getVirtualBounds().width < virtualBounds.width) {
+ redraw();
+ }
}
void scrollToEnd() {
@@ -535,9 +563,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..c24a35857f1 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;
@@ -95,6 +96,16 @@ public void drawLine(ITextCanvasModel model, GC gc, int line, int x, int y, int
setupGC(gc, style);
Point start = model.getSelectionStart();
Point end = model.getSelectionEnd();
+ // Everything the selection covers on this line goes down first. Drawing
+ // it as the text is drawn leaves out whatever the text does not reach:
+ // the cells past the last character a program put on the line, and the
+ // cells where the font draws a glyph narrower than the one it sits in.
+ int from = Math.max(start.y == line ? start.x : 0, colFirst);
+ int to = end.y == line ? Math.min(end.x + 1, colLast) : colLast;
+ if (to > from) {
+ gc.fillRectangle(x + (from - colFirst) * getCellWidth(), y, (to - from) * getCellWidth(),
+ getCellHeight());
+ }
char[] chars = model.getTerminalText().getChars(line);
if (chars != null) {
int offset = 0;
@@ -159,19 +170,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/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 f972bbbe28b..ce0a1d3c4cf 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
@@ -156,4 +156,15 @@ public interface ITerminalTextData extends ITerminalTextDataReadOnly {
*/
void setWrappedLine(int line);
+ /**
+ * Says the line ends where it is drawn, undoing {@link #setWrappedLine(int)}.
+ * A program that draws its own screen writes a line over and over, and what ran
+ * on in one frame may end in the next.
+ *
+ * @param line line to mark, must be >=0 and < {@link #getHeight()}
+ * @since 1.2
+ */
+ default void clearWrappedLine(int line) {
+ }
+
}
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..ff9736d39bf 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,117 @@ 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));
+ }
+
+ @Test
+ public void testWrappedLineMarkComesOff() {
+ ITerminalTextData term = makeITerminalTextData();
+ IVT100EmulatorBackend vt100 = makeBakend(term);
+ term.setMaxHeight(3);
+ vt100.setDimensions(3, 5);
+ vt100.setCursor(0, 0);
+ // a line the terminal folded is marked as running on to the next
+ vt100.appendString("abcdefg");
+ assertTrue(term.isWrappedLine(0));
+ assertFalse(term.isWrappedLine(1));
+ // drawn over shorter, the line ends there and the mark comes off
+ vt100.setCursor(0, 0);
+ vt100.appendString("xyz");
+ assertFalse(term.isWrappedLine(0));
+ // erasing to the end of the line says the same
+ vt100.setCursor(0, 0);
+ vt100.appendString("abcdefg");
+ assertTrue(term.isWrappedLine(0));
+ vt100.setCursor(0, 3);
+ vt100.eraseLineToEnd();
+ assertFalse(term.isWrappedLine(0));
+ }
}
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 f1d3857ba27..457b4ebc943 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
@@ -654,5 +654,8 @@ public void testWrappedLines() {
assertTrue(term.isWrappedLine(3));
term.cleanLine(0);
assertFalse(term.isWrappedLine(0));
+ term.setWrappedLine(2);
+ term.clearWrappedLine(2);
+ assertFalse(term.isWrappedLine(2));
}
}
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
+ }
+}
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 163af3be430..b4be30273ad 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
@@ -441,5 +441,9 @@ public void testWrappedLines() {
assertTrue(term.isWrappedLine(3));
term.cleanLine(3);
assertFalse(term.isWrappedLine(3));
+ term.setWrappedLine(3);
+ term.clearWrappedLine(3);
+ assertFalse(term.isWrappedLine(3));
+ term.clearWrappedLine(0); // outside window, harmless
}
}