diff --git a/pom.xml b/pom.xml index 974b42a4..3db7a45a 100644 --- a/pom.xml +++ b/pom.xml @@ -4,7 +4,7 @@ com.alibaba.middleware termd-core - 1.1.7.15 + 1.1.7.16-SNAPSHOT jar 4.0.0 diff --git a/src/main/java/io/termd/core/http/HttpTtyConnection.java b/src/main/java/io/termd/core/http/HttpTtyConnection.java index 916ebc44..5cb9423a 100644 --- a/src/main/java/io/termd/core/http/HttpTtyConnection.java +++ b/src/main/java/io/termd/core/http/HttpTtyConnection.java @@ -130,7 +130,7 @@ public void writeToDecoder(String msg) { if ("read".equals(action)) { lastAccessedTime = System.currentTimeMillis(); String data = obj.getString("data"); - decoder.write(data.getBytes()); //write back echo + decoder.write(data.getBytes(charset)); // 按连接字符集进入统一解码链路 } else if ("resize".equals(action)) { try { int cols = obj.containsKey("cols") ? obj.getIntValue("cols") : size.x(); diff --git a/src/main/java/io/termd/core/readline/LineBuffer.java b/src/main/java/io/termd/core/readline/LineBuffer.java index 70edca95..8e826b02 100644 --- a/src/main/java/io/termd/core/readline/LineBuffer.java +++ b/src/main/java/io/termd/core/readline/LineBuffer.java @@ -96,8 +96,8 @@ public LineBuffer insert(int cp) { if (cp != '\n') { throw new IllegalArgumentException("LineBuffer can only contain \\n control char"); } - } else if (w != 1) { - throw new IllegalArgumentException("LineBuffer cannot contain chars of width!=1 for the moment"); + } else if (cp == 0) { + throw new IllegalArgumentException("LineBuffer cannot contain the null control char"); } if (cursor < size) { System.arraycopy(data, cursor, data, cursor + 1, size - cursor); @@ -303,13 +303,123 @@ private int findEndOfLine(int offset) { } public void update(LineBuffer dst, Consumer out, int width) { - new Update(out, width).perform(dst); + if (containsNonSingleWidth() || dst.containsNonSingleWidth()) { + new Redraw(out, width).perform(dst); + } else { + new Update(out, width).perform(dst); + } + } + + private boolean containsNonSingleWidth() { + for (int i = 0; i < size; i++) { + int codePoint = data[i]; + if (codePoint != '\n' && Wcwidth.of(codePoint) != 1) { + return true; + } + } + return false; + } + + /** + * 非单宽字符采用整行重绘。终端显示的基本单位是单元格,整行重绘可以直接处理宽字符和组合字符, + * 也能在删除组合字符时清除已经附着在基础字符上的旧字形。 + */ + private class Redraw { + + private final Consumer out; + private final int width; + private int scrCol; + private int scrRow; + + private Redraw(Consumer out, int width) { + this.out = out; + this.width = width; + Vector position = getCursorPosition(width); + this.scrCol = position.x(); + this.scrRow = position.y(); + } + + private void perform(LineBuffer dst) { + moveCursor(0, 0); + + int lastSourceRow = getPosition(size, width).y(); + for (int row = 0; row <= lastSourceRow; row++) { + out.accept(new int[]{'\033', '[', 'K'}); + if (row < lastSourceRow) { + out.accept(new int[]{'\033', '[', '1', 'B'}); + scrRow++; + } + } + moveCursor(0, 0); + + if (dst.size > 0) { + out.accept(dst.toArray()); + } + Vector end = dst.getPosition(dst.size, width); + scrCol = end.x(); + scrRow = end.y(); + + if (dst.endsAtRightMargin(width)) { + out.accept(new int[]{' ', '\r'}); + scrCol = 0; + } + + Vector cursorPosition = dst.getCursorPosition(width); + moveCursor(cursorPosition.x(), cursorPosition.y()); + + data = dst.data.clone(); + cursor = dst.cursor; + size = dst.size; + } + + private void moveCursor(int col, int row) { + if (scrCol != col) { + if (col == 0) { + out.accept(new int[]{'\r'}); + scrCol = 0; + } else { + while (scrCol != col) { + if (scrCol < col) { + scrCol++; + out.accept(new int[]{'\033', '[', '1', 'C'}); + } else { + scrCol--; + out.accept(new int[]{'\b'}); + } + } + } + } + while (scrRow != row) { + if (row < scrRow) { + scrRow--; + out.accept(new int[]{'\033', '[', '1', 'A'}); + } else { + scrRow++; + out.accept(new int[]{'\033', '[', '1', 'B'}); + } + } + } + } + + private boolean endsAtRightMargin(int width) { + if (getPosition(size, width).x() != 0) { + return false; + } + for (int i = size - 1; i >= 0; i--) { + int codePoint = data[i]; + if (codePoint == '\n') { + return false; + } + if (Wcwidth.of(codePoint) > 0) { + return true; + } + } + return false; } // The update algorithm encapsulated in an inner class // todo : use term capabilities instead of hardcoded ansi programming // todo : support other control chars - // todo : support codepoint of with != 1 (like combining chars, etc...) // todo : issue existing chars for moving right instead of cursor left movement private class Update { diff --git a/src/main/java/io/termd/core/readline/Readline.java b/src/main/java/io/termd/core/readline/Readline.java index 49e91c5d..d8f42e15 100644 --- a/src/main/java/io/termd/core/readline/Readline.java +++ b/src/main/java/io/termd/core/readline/Readline.java @@ -291,9 +291,10 @@ void resize(int oldWith, int newWidth) { // Erase screen LineBuffer abc = new LineBuffer(buffer.getCapacity()); - abc.insert(currentPrompt); + int[] promptCodePoints = Helper.toCodePoints(currentPrompt); + abc.insert(promptCodePoints); abc.insert(buffer.toArray()); - abc.setCursor(currentPrompt.length() + buffer.getCursor()); + abc.setCursor(promptCodePoints.length + buffer.getCursor()); // Recompute new cursor Vector pos = abc.getCursorPosition(newWidth); @@ -371,9 +372,10 @@ public Vector size() { */ public void redraw() { LineBuffer toto = new LineBuffer(buffer.getCapacity()); - toto.insert(Helper.toCodePoints(currentPrompt)); + int[] promptCodePoints = Helper.toCodePoints(currentPrompt); + toto.insert(promptCodePoints); toto.insert(buffer.toArray()); - toto.setCursor(currentPrompt.length() + buffer.getCursor()); + toto.setCursor(promptCodePoints.length + buffer.getCursor()); LineBuffer abc = new LineBuffer(toto.getCapacity()); abc.update(toto, conn.stdoutHandler(), size.x()); } @@ -391,13 +393,14 @@ public Interaction refresh(LineBuffer buffer) { private void refresh(LineBuffer update, int width) { LineBuffer copy3 = new LineBuffer(update.getCapacity()); final List codePoints = new LinkedList(); - copy3.insert(Helper.toCodePoints(currentPrompt)); + int[] promptCodePoints = Helper.toCodePoints(currentPrompt); + copy3.insert(promptCodePoints); copy3.insert(buffer().toArray()); - copy3.setCursor(currentPrompt.length() + buffer().getCursor()); + copy3.setCursor(promptCodePoints.length + buffer().getCursor()); LineBuffer copy2 = new LineBuffer(copy3.getCapacity()); - copy2.insert(Helper.toCodePoints(currentPrompt)); + copy2.insert(promptCodePoints); copy2.insert(update.toArray()); - copy2.setCursor(currentPrompt.length() + update.getCursor()); + copy2.setCursor(promptCodePoints.length + update.getCursor()); copy3.update(copy2, new Consumer() { @Override public void accept(int[] data) { diff --git a/src/test/java/io/termd/core/http/HttpTtyConnectionTest.java b/src/test/java/io/termd/core/http/HttpTtyConnectionTest.java new file mode 100644 index 00000000..e8014835 --- /dev/null +++ b/src/test/java/io/termd/core/http/HttpTtyConnectionTest.java @@ -0,0 +1,51 @@ +package io.termd.core.http; + +import io.termd.core.function.Consumer; +import io.termd.core.util.Helper; +import io.termd.core.util.Vector; +import org.junit.Test; + +import java.nio.charset.Charset; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.TimeUnit; + +import static org.junit.Assert.assertEquals; + +public class HttpTtyConnectionTest { + + @Test + public void testTextInputUsesConnectionCharset() { + final List actual = new ArrayList(); + HttpTtyConnection connection = new HttpTtyConnection( + Charset.forName("UTF-16BE"), new Vector(80, 24)) { + @Override + protected void write(byte[] buffer) { + } + + @Override + public void execute(Runnable task) { + task.run(); + } + + @Override + public void schedule(Runnable task, long delay, TimeUnit unit) { + task.run(); + } + + @Override + public void close() { + } + }; + connection.setStdinHandler(new Consumer() { + @Override + public void accept(int[] codePoints) { + actual.addAll(Helper.list(codePoints)); + } + }); + + connection.writeToDecoder("{\"action\":\"read\",\"data\":\"中文\"}"); + + assertEquals(Helper.list(Helper.toCodePoints("中文")), actual); + } +} diff --git a/src/test/java/io/termd/core/io/BinaryEncodingTest.java b/src/test/java/io/termd/core/io/BinaryEncodingTest.java index 6aace853..d3303c50 100644 --- a/src/test/java/io/termd/core/io/BinaryEncodingTest.java +++ b/src/test/java/io/termd/core/io/BinaryEncodingTest.java @@ -23,6 +23,7 @@ public class BinaryEncodingTest { public void testChars() throws IOException { testChars("A", 65); testChars("\u20AC", -30, -126, -84); // Euro + testChars("中文", -28, -72, -83, -26, -106, -121); testChars(new StringBuilder().appendCodePoint(66231).toString(), -16, -112, -118, -73); // Surrogate } diff --git a/src/test/java/io/termd/core/readline/LineBufferTest.java b/src/test/java/io/termd/core/readline/LineBufferTest.java index fdbd8893..293ed04e 100644 --- a/src/test/java/io/termd/core/readline/LineBufferTest.java +++ b/src/test/java/io/termd/core/readline/LineBufferTest.java @@ -229,7 +229,7 @@ public void testCursorInvisibleChar() { assertEquals(new Vector(0, 2), buffer.getCursorPosition(1)); } - // @Test + @Test public void testCursorPositionWithMultiCell1() { LineBuffer buffer = new LineBuffer(); buffer.insert('한', 'b'); @@ -243,7 +243,7 @@ public void testCursorPositionWithMultiCell1() { } } - // @Test + @Test public void testCursorPositionWithMultiCell2() { LineBuffer buffer = new LineBuffer(); buffer.insert('a', '한'); diff --git a/src/test/java/io/termd/core/readline/ReadlineTest.java b/src/test/java/io/termd/core/readline/ReadlineTest.java index 51e832a4..b118fb7a 100644 --- a/src/test/java/io/termd/core/readline/ReadlineTest.java +++ b/src/test/java/io/termd/core/readline/ReadlineTest.java @@ -10,6 +10,7 @@ import java.util.Collections; import java.util.LinkedList; +import java.util.concurrent.atomic.AtomicReference; /** * @author Julien Viet @@ -263,6 +264,99 @@ public void testBuffering() { term.assertAt(2, 0); } + @Test + public void testChineseInput() { + TestTerm term = new TestTerm(this); + Supplier line = term.readlineComplete(); + term.read('中', '文', '\r'); + assertEquals("中文", line.get()); + assertEquals(0, term.getBellCount()); + } + + @Test + public void testChineseEditing() { + TestTerm term = new TestTerm(this); + Supplier line = term.readlineComplete(); + term.read('A', '中', 'B'); + term.assertScreen("% A中B"); + term.assertAt(0, 6); + + term.read(BACKWARD_KEY); + term.assertAt(0, 5); + term.read(BACKWARD_DELETE_KEY); + term.assertScreen("% AB"); + term.assertAt(0, 3); + + term.read('\r'); + assertEquals("AB", line.get()); + } + + @Test + public void testChineseWrapsBeforeLastCell() { + TestTerm term = new TestTerm(this).setWidth(5); + term.readlineFail(); + term.read('A', 'B', '中', 'C'); + term.assertScreen("% AB", "中C"); + term.assertAt(1, 3); + + term.read(BACKWARD_KEY); + term.assertAt(1, 2); + term.read(BACKWARD_KEY); + term.assertAt(0, 4); + } + + @Test + public void testSupplementaryUnicodePrompt() { + TestTerm term = new TestTerm(this); + final AtomicReference line = new AtomicReference(); + String prompt = new String(Character.toChars(0x20000)) + "> "; + term.readline.readline(term.conn, prompt, new Consumer() { + @Override + public void accept(String value) { + line.set(value); + } + }); + + term.read('A', 'B'); + term.assertScreen(prompt + "AB"); + term.assertAt(0, 6); + term.read(BACKWARD_KEY); + term.assertAt(0, 5); + term.read('\r'); + assertEquals("AB", line.get()); + } + + @Test + public void testCombiningCharacterEditing() { + TestTerm term = new TestTerm(this); + Supplier line = term.readlineComplete(); + term.read('e', '\u0301', 'X'); + term.assertScreen("% e\u0301X"); + term.assertAt(0, 4); + + term.read(BACKWARD_KEY); + term.assertAt(0, 3); + term.read(BACKWARD_DELETE_KEY); + term.assertScreen("% eX"); + term.assertAt(0, 3); + + term.read('\r'); + assertEquals("eX", line.get()); + } + + @Test + public void testSupplementaryUnicodeInput() { + TestTerm term = new TestTerm(this); + Supplier line = term.readlineComplete(); + int codePoint = 0x20000; + String value = new String(Character.toChars(codePoint)); + term.read(codePoint, 'X'); + term.assertScreen("% " + value + "X"); + term.assertAt(0, 5); + term.read('\r'); + assertEquals(value + "X", line.get()); + } + @Test public void testHistory() { TestTerm term = new TestTerm(this); diff --git a/src/test/java/io/termd/core/readline/TestTerm.java b/src/test/java/io/termd/core/readline/TestTerm.java index aeeb15fc..2d82990e 100644 --- a/src/test/java/io/termd/core/readline/TestTerm.java +++ b/src/test/java/io/termd/core/readline/TestTerm.java @@ -38,6 +38,7 @@ import io.termd.core.tty.TtyEvent; import io.termd.core.tty.TtyOutputMode; import io.termd.core.util.Vector; +import io.termd.core.util.Wcwidth; import java.nio.charset.Charset; import java.util.ArrayList; @@ -53,7 +54,7 @@ class TestTerm { private TestBase readlineTest; - private int[][] buffer = new int[10][]; + private String[][] buffer = new String[10][]; private int row; private int cursor; private int status = 0; @@ -65,13 +66,12 @@ class TestTerm { public void accept(int[] event) { for (int i : event) { if (buffer[row] == null) { - buffer[row] = new int[100]; + buffer[row] = new String[100]; } switch (status) { case 0: if (i >= 32) { - buffer[row][cursor] = i; - forward(); + writeCodePoint(i); } else { switch (i) { case 7: @@ -133,7 +133,7 @@ public void accept(int[] event) { throw new UnsupportedOperationException("Not yet implemented"); } else { for (int j = cursor;j < buffer[row].length;j++) { - buffer[row][j] = 0; + buffer[row][j] = null; } } break; @@ -151,6 +151,43 @@ public void accept(int[] event) { } } + private void writeCodePoint(int codePoint) { + int cellWidth = Wcwidth.of(codePoint); + if (cellWidth == 0) { + appendCombiningCodePoint(codePoint); + return; + } + if (cursor + cellWidth > width) { + cursor = 0; + row++; + if (buffer[row] == null) { + buffer[row] = new String[100]; + } + } + buffer[row][cursor] = new String(Character.toChars(codePoint)); + for (int i = 1; i < cellWidth; i++) { + buffer[row][cursor + i] = ""; + } + for (int i = 0; i < cellWidth; i++) { + forward(); + } + } + + private void appendCombiningCodePoint(int codePoint) { + int targetRow = row; + int targetCol = cursor - 1; + if (targetCol < 0 && targetRow > 0) { + targetRow--; + targetCol = width - 1; + } + while (targetCol >= 0 && "".equals(buffer[targetRow][targetCol])) { + targetCol--; + } + if (targetCol >= 0 && buffer[targetRow][targetCol] != null) { + buffer[targetRow][targetCol] += new String(Character.toChars(codePoint)); + } + } + private void backward() { if (cursor > 0) { cursor--; @@ -221,7 +258,7 @@ public Consumer getSizeHandler() { public void setSizeHandler(Consumer handler) { sizeHandler = handler; if (handler != null) { - handler.accept(new Vector(40, 20)); + handler.accept(new Vector(width, 20)); } } @@ -361,16 +398,16 @@ public void resetBellCount() { private List render() { List lines = new ArrayList(); - for (int[] row : buffer) { + for (String[] row : buffer) { if (row == null) { break; } StringBuilder line = new StringBuilder(); - for (int codePoint : row) { - if (codePoint < 32) { + for (String cell : row) { + if (cell == null) { break; } - line.appendCodePoint(codePoint); + line.append(cell); } lines.add(line.toString()); }