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 } }