From aa51158497d4b3194375ea76ec29b4be7936008a Mon Sep 17 00:00:00 2001 From: luo-cheng-xi Date: Sat, 21 Sep 2024 16:14:45 -0400 Subject: [PATCH] fix: can solve character that need 2 width now. --- README.md | 6 ++ term.go | 206 +++++++++++++++++++++++++++++++++------------- test/term_test.go | 90 ++++++++++++++++---- 3 files changed, 231 insertions(+), 71 deletions(-) diff --git a/README.md b/README.md index 9b61898..708a0d5 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,12 @@ Write a string into VirtualTerm, you will know what would your string be like if ## Now Support 😎 + +### Character Encoding +* ASCII +* UTF-8 + +### Control Characters * `\b` backspace * `\r` carriage return * `\n` feed line diff --git a/term.go b/term.go index d5e19be..a86b8a2 100644 --- a/term.go +++ b/term.go @@ -9,7 +9,9 @@ import ( "strings" ) -var ErrNotSupportedCSI = errors.New("this csi not supported") +var INF = math.MaxInt - 1 +var ErrCannotHandle = errors.New("this csi is not supported or syntax error") +var ErrNonDetermistics = errors.New("non-deterministic") // VirtualTerm this is created to simulate a terminal,handle the special character such as '\r','\b', "\033[1D". // For example: if you input "cute\rhat", the result of String() would be "hate" @@ -21,6 +23,9 @@ type VirtualTerm struct { // the content of virtual terminal content [][]rune + // xOffset is the offset on X. + xOffset int + // silence will shut down the log.By default, it is true silence bool } @@ -68,7 +73,7 @@ func (vt *VirtualTerm) handleCSI(csi string) error { // an example \033[A // csi should not be shorter than 3 if len(csi) < 3 { - return fmt.Errorf("too short, %w", ErrNotSupportedCSI) + return fmt.Errorf("too short, %w", ErrCannotHandle) } param := csi[2 : len(csi)-1] // final byte is a single character @@ -78,87 +83,78 @@ func (vt *VirtualTerm) handleCSI(csi string) error { case 'A': // move the cursor up var cnt int - cnt, err = strconv.Atoi(param) + if len(param) == 0 { + cnt = 1 + } else { + cnt, err = strconv.Atoi(param) + } + if err != nil { + return fmt.Errorf("%w:ESC[#A param may be wrong", err) + } vt.cursorMove(0, -cnt) case 'B': // move the cursor down var cnt int - cnt, err = strconv.Atoi(param) + if len(param) == 0 { + cnt = 1 + } else { + cnt, err = strconv.Atoi(param) + } + if err != nil { + return fmt.Errorf("%w:ESC[#B param may be wrong", err) + } vt.cursorMove(0, cnt) case 'C': // move the cursor right var cnt int - cnt, err = strconv.Atoi(param) + if len(param) == 0 { + cnt = 1 + } else { + cnt, err = strconv.Atoi(param) + } + if err != nil { + return fmt.Errorf("%w:ESC[#C param may be wrong", err) + } vt.cursorMove(cnt, 0) case 'D': // move the cursor left var cnt int - cnt, err = strconv.Atoi(param) + if len(param) == 0 { + cnt = 1 + } else { + cnt, err = strconv.Atoi(param) + } + if err != nil { + return fmt.Errorf("%w:ESC[#D param may be wrong", err) + } vt.cursorMove(-cnt, 0) case 'H': // move the cursor to the home position vt.cursorHome() default: - return fmt.Errorf("%q%w", csi, ErrNotSupportedCSI) - } - if err != nil { - return fmt.Errorf("failed to handle csi: %w", err) + return fmt.Errorf("%q%w", csi, ErrCannotHandle) } return nil } // isIntermediateByte check whether it is CSI param byte -func isIntermediateByte(c byte) bool { +func isIntermediateRune(c rune) bool { return c >= 0x20 && c <= 0x2F } // isParameterByte check whether it is CSI parameter byte -func isParameterByte(c byte) bool { +func isParameterRune(c rune) bool { return c >= 0x30 && c <= 0x3F } // isCSIFinalByte check whether it is CSI final byte -func isCSIFinalByte(c byte) bool { +func isCSIFinalRune(c rune) bool { return c >= 0x40 && c <= 0x7E } // Write implements the io.Writer interface -func (vt *VirtualTerm) Write(p []byte) (n int, err error) { - for i := 0; i < len(p); i++ { - switch p[i] { - case '\r': - // Carriage Return - vt.cursorMove(math.MinInt, 0) - case '\n': - // NewLine - // If the cursor is on the last line, add a new line - vt.cursorMove(math.MinInt, 1) - case '\b': - vt.cursorMove(-1, 0) - case '\033': - idx := i + 1 - for idx < len(p) && (!isCSIFinalByte(p[idx]) || (idx == i+1 && p[idx] == '[')) { - idx++ - } - // stopped because it is the final byte - if idx != len(p) { - csi := p[i : idx+1] - err = vt.handleCSI(string(csi)) - if err != nil { - if errors.Is(err, ErrNotSupportedCSI) { - vt.log("warning, some sci is not supported") - } else { - return 0, err - } - } - } - i = idx - default: - vt.writeRune(rune(p[i])) - } - - } - return len(p), nil +func (vt *VirtualTerm) Write(b []byte) (n int, err error) { + return vt.WriteRunes([]rune(string(b))) } // WriteString write string to virtual terminal @@ -166,9 +162,62 @@ func (vt *VirtualTerm) WriteString(s string) (n int, err error) { return vt.Write([]byte(s)) } +// runeWidth +func (*VirtualTerm) runeWidth(r rune) int { + if len(string(r)) >= 3 { + return 2 + } else { + return 1 + } +} + // cursorMove can control the cursor func (vt *VirtualTerm) cursorMove(x int, y int) { - vt.cx = max(vt.cx+x, 0) + // handle the offset first + if vt.xOffset != 0 { + x += vt.xOffset + vt.xOffset = 0 + } + if x < 0 { + // move the cursor left + // this may cause offset + x = -x + var far int + for vt.cx > 0 && x > 0 { + far = vt.runeWidth(vt.content[vt.cy][vt.cx-1]) + if x >= far { + x -= far + vt.cx-- + } else { + break + } + } + if x == 1 && vt.cx != 0 { + vt.xOffset = -1 + } + } else if x > 0 { + // move the cursor right + // this situation will not cause offset + for x > 0 { + // if cx is out of bound, add empty element + if vt.cx >= len(vt.content[vt.cy])-1 { + vt.content[vt.cy] = append(vt.content[vt.cy], ' ') + } + far := vt.runeWidth(vt.content[vt.cy][vt.cx]) + if far <= x { + x -= far + vt.cx++ + } else { + break + } + } + if x == 1 { + vt.xOffset = -1 + vt.cx++ + } + } + + // avoid index out of bound vt.cy = max(vt.cy+y, 0) for vt.cy >= len(vt.content) { vt.content = append(vt.content, []rune{' '}) @@ -182,19 +231,64 @@ func (vt *VirtualTerm) cursorMove(x int, y int) { func (vt *VirtualTerm) cursorHome() { vt.cx = 0 vt.cy = 0 + vt.xOffset = 0 } // writeRune write Rune to content. -func (vt *VirtualTerm) writeRune(r rune) { +func (vt *VirtualTerm) writeRune(r rune) error { + // if the offset of cursor is not zero, means that there will be non-deterministic for the output + // For example, if your output is "δ½ ε₯½\bCOOL", than it might be "δ½ ε₯½OOL"(git bash in Windows) or δ½  COOL("Windows powershell") + // So it should be treated as an error. + if vt.xOffset != 0 { + return ErrNonDetermistics + } + // get the width of rune + far := vt.runeWidth(r) vt.content[vt.cy][vt.cx] = r - vt.cursorMove(1, 0) + vt.cursorMove(far, 0) + + return nil } -// writeRunes write Rune array to content -func (vt *VirtualTerm) writeRunes(rs []rune) { - for _, r := range rs { - vt.writeRune(r) +// WriteRunes write Rune array to content +func (vt *VirtualTerm) WriteRunes(p []rune) (n int, err error) { + for i := 0; i < len(p); i++ { + switch p[i] { + case '\r': + // Carriage Return + vt.cursorMove(-INF, 0) + case '\n': + // NewLine + // If the cursor is on the last line, add a new line + vt.cursorMove(-INF, 1) + case '\b': + vt.cursorMove(-1, 0) + case '\033': + idx := i + 1 + for idx < len(p) && (!isCSIFinalRune(p[idx]) || (idx == i+1 && p[idx] == '[')) { + idx++ + } + // stopped because it is the final byte + if idx != len(p) { + csi := p[i : idx+1] + err = vt.handleCSI(string(csi)) + if err != nil { + if errors.Is(err, ErrCannotHandle) { + vt.log("warning, some sci is not supported") + } else { + return 0, err + } + } + } + i = idx + default: + err = vt.writeRune(p[i]) + if err != nil { + return 0, err + } + } } + return len(p), nil } // writeString write String to content diff --git a/test/term_test.go b/test/term_test.go index 3e132b3..627067f 100644 --- a/test/term_test.go +++ b/test/term_test.go @@ -1,16 +1,30 @@ package test import ( + "errors" "fmt" "github.com/chengxilo/virtualterm" "github.com/stretchr/testify/assert" + "log" "testing" ) func TestVirtualTerm(t *testing.T) { - str := "hello\rvirtuaa\bl-terminal" - ns, _ := virtualterm.Process(str) - assert.Equal(t, ns, "virtual-terminal") + tests := []struct { + input string + output string + }{ + {"hello\rvirtuaa\bl-terminal", "virtual-terminal"}, + {"100% |β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ| (100/100 B, 100 B/s, 100 it/s)", "100% |β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ| (100/100 B, 100 B/s, 100 it/s)"}, + {"πŸ¦πŸ¦‘πŸ™\rπŸ¦žπŸ¦€\n🐚\b\bπŸ¦†πŸ“", "πŸ¦žπŸ¦€πŸ™\nπŸ¦†πŸ“"}, + } + for _, te := range tests { + s, err := virtualterm.Process(te.input) + if err != nil { + t.Fatal(err) + } + assert.Equal(t, te.output, s) + } } func ExampleVirtualTerm() { @@ -56,7 +70,10 @@ func TestNewLine(t *testing.T) { {"I want to be the\rworld444\nWhat is this\n", "world444o be the\nWhat is this\n"}, } for _, te := range tests { - vt.Write([]byte(te.input)) + _, err := vt.Write([]byte(te.input)) + if err != nil { + t.Fatal(err) + } actual := vt.String() assert.Equal(t, te.output, actual) vt.Clear() @@ -71,36 +88,79 @@ func TestBackspace(t *testing.T) { }{ {"I want to be the\rworld444\nWhat is this\bh\n", "world444o be the\nWhat is thih\n"}, {"I want to be the\rworld444\nWhat is this\n\bh\n", "world444o be the\nWhat is this\nh\n"}, + {"ι”„η¦Ύ\b\bζ—₯\b\b\b\bε½“εˆ", "ε½“εˆ"}, } for _, te := range tests { - vt.Write([]byte(te.input)) + _, err := vt.Write([]byte(te.input)) + if err != nil { + t.Fatal(err) + } actual := vt.String() assert.Equal(t, te.output, actual) vt.Clear() } } -func TestControlSequenceIntroducer(t *testing.T) { +func TestCSI(t *testing.T) { vt := virtualterm.NewDefault() tests := []struct { - input string - output string + input string + expected string }{ - {"hello\033[Hworl", "worlo"}, {"\033[123*", ""}, - {"\033[123helloworld", "elloworld"}, - {"\033[323[21helloworld", "21helloworld"}, + {"δ½ ε₯½\r\033[4Cε•Š", "δ½ ε₯½ε•Š"}, + {"δ½ ε₯½\r\033[C", "δ½ ε₯½"}, + {"Hello\033[1D\bWorld", "HelWorld"}, + {"Hello\033[2D\b\bWorld", "HWorld"}, + {"Hello\033[5CWorld", "Hello World"}, + {"Hello\033[1AWorld", "HelloWorld"}, + {"Hello\033[1BWorld", "Hello\n World"}, + {"Hello\033[1D\033[1CWorld", "HelloWorld"}, + {"Hello\033[1A\033[1BWorld", "Hello\n World"}, + {"Hello\033[2D\b\b\033[2CWorld", "HelWorld"}, + {"Hello\033[1A\033[1B\033[1D\bWorld", "Hello\n World"}, {"\033helloworld", "elloworld"}, {"\033[2Bhello\033[Hworld", "world\n\nhello"}, {"hello\033[3Dworld\033[2CTo be or not to be\033[3Bsecond hello\033[2Aworld\033[4Bbalabala", "heworld To be or not to be\n world\n\n second hello\n\n balabala"}, + {"ι”„η¦Ύζ—₯\b\033[1Cε½“εˆ", "ι”„η¦Ύζ—₯ε½“εˆ"}, + {"δ½ ε₯½\b\033[2Cε‘€", "δ½ ε₯½ ε‘€"}, + {"ι”„η¦Ύζ—₯ε½“εˆ\nζ±—ζ»΄η¦ΎδΈ‹εœŸ\033[H谁ηŸ₯η›˜δΈ­ι€\rη²’η²’ηš†θΎ›θ‹¦", "η²’η²’ηš†θΎ›θ‹¦\nζ±—ζ»΄η¦ΎδΈ‹εœŸ"}, + {"πŸ€πŸ€\033[2D\b\bπŸ“δ½ ε€ͺ美", "πŸ“δ½ ε€ͺ美"}, + {"\033[323[21helloworld", "21helloworld"}, + {"\033[123helloworld", "elloworld"}, + {"\bレ\033[2Dヒン", "ヒン"}, } - for _, te := range tests { - _, err := vt.Write([]byte(te.input)) + for i, te := range tests { + _, err := vt.WriteString(te.input) if err != nil { - t.Fatal(err) + t.Fatal(err, i) + } + actual := vt.String() + if actual != te.expected { + log.Print("actual: "+actual+"expected: ", te.expected, "test index: ", i) + t.Fail() } - assert.Equal(t, te.output, vt.String()) vt.Clear() } } + +func TestInvalidInput(t *testing.T) { + vt := virtualterm.NewDefault() + tests := []struct { + input string + }{ + {"ζˆ‘ζ˜―\b猫"}, + {"ζˆ‘ζ˜―\033[1D猫"}, + {"ζˆ‘ζ˜―\bhero"}, + {"ι”„η¦Ύζ—₯\b\033当[1C午"}, + {"\bレ\033[Dヒン"}, + } + for _, te := range tests { + _, err := vt.WriteString(te.input) + if !errors.Is(err, virtualterm.ErrNonDetermistics) { + t.Logf("error is not expected") + t.Fail() + } + } +}