diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c436f400..56de7b5b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,7 +1,7 @@ name: Continuous Integration env: - GO_VERSION: 1.20 + GO_VERSION: 1.25 on: push: @@ -18,7 +18,7 @@ jobs: - name: Setup Go uses: actions/setup-go@v4 with: - go-version: 1.20.x + go-version: 1.25.x - name: Test code run: | go test ./... diff --git a/edit.go b/edit.go index 4ced2ad0..44601805 100644 --- a/edit.go +++ b/edit.go @@ -4,10 +4,6 @@ package gocui -import ( - "unicode" -) - // Editor interface must be satisfied by gocui editors. type Editor interface { Edit(v *View, key Key, ch rune, mod Modifier) bool @@ -29,6 +25,9 @@ var DefaultEditor Editor = EditorFunc(SimpleEditor) // SimpleEditor is used as the default gocui editor. func SimpleEditor(v *View, key Key, ch rune, mod Modifier) bool { switch { + case (key == KeyBackspace || key == KeyBackspace2) && (mod&ModAlt) != 0, + key == KeyCtrlW: + v.TextArea.BackSpaceWord() case key == KeyBackspace || key == KeyBackspace2: v.TextArea.BackSpaceChar() case key == KeyCtrlD || key == KeyDelete: @@ -37,18 +36,18 @@ func SimpleEditor(v *View, key Key, ch rune, mod Modifier) bool { v.TextArea.MoveCursorDown() case key == KeyArrowUp: v.TextArea.MoveCursorUp() - case key == KeyArrowLeft && (mod&ModAlt) != 0: + case (key == KeyArrowLeft || ch == 'b') && (mod&ModAlt) != 0: v.TextArea.MoveLeftWord() case key == KeyArrowLeft: v.TextArea.MoveCursorLeft() - case key == KeyArrowRight && (mod&ModAlt) != 0: + case (key == KeyArrowRight || ch == 'f') && (mod&ModAlt) != 0: v.TextArea.MoveRightWord() case key == KeyArrowRight: v.TextArea.MoveCursorRight() case key == KeyEnter: - v.TextArea.TypeRune('\n') + v.TextArea.TypeCharacter("\n") case key == KeySpace: - v.TextArea.TypeRune(' ') + v.TextArea.TypeCharacter(" ") case key == KeyInsert: v.TextArea.ToggleOverwrite() case key == KeyCtrlU: @@ -63,8 +62,8 @@ func SimpleEditor(v *View, key Key, ch rune, mod Modifier) bool { v.TextArea.BackSpaceWord() case key == KeyCtrlY: v.TextArea.Yank() - case unicode.IsPrint(ch): - v.TextArea.TypeRune(ch) + case ch != 0: + v.TextArea.TypeCharacter(string(ch)) default: return false } diff --git a/escape.go b/escape.go index f559bbb2..7c500d9a 100644 --- a/escape.go +++ b/escape.go @@ -6,18 +6,19 @@ package gocui import ( "strconv" + "strings" "github.com/go-errors/errors" ) type escapeInterpreter struct { state escapeState - curch rune + curch string csiParam []string curFgColor, curBgColor Attribute mode OutputMode instruction instruction - hyperlink string + hyperlink strings.Builder } type ( @@ -68,20 +69,20 @@ var ( errOSCParseError = errors.New("OSC escape sequence parsing error") ) -// runes in case of error will output the non-parsed runes as a string. -func (ei *escapeInterpreter) runes() []rune { +// characters in case of error will output the non-parsed characters as a string. +func (ei *escapeInterpreter) characters() []string { switch ei.state { case stateNone: - return []rune{0x1b} + return []string{"\x1b"} case stateEscape: - return []rune{0x1b, ei.curch} + return []string{"\x1b", ei.curch} case stateCSI: - return []rune{0x1b, '[', ei.curch} + return []string{"\x1b", "[", ei.curch} case stateParams: - ret := []rune{0x1b, '['} + ret := []string{"\x1b", "["} for _, s := range ei.csiParam { - ret = append(ret, []rune(s)...) - ret = append(ret, ';') + ret = append(ret, s) + ret = append(ret, ";") } return append(ret, ei.curch) default: @@ -114,10 +115,10 @@ func (ei *escapeInterpreter) instructionRead() { ei.instruction = noInstruction{} } -// parseOne parses a rune. If isEscape is true, it means that the rune is part -// of an escape sequence, and as such should not be printed verbatim. Otherwise, -// it's not an escape sequence. -func (ei *escapeInterpreter) parseOne(ch rune) (isEscape bool, err error) { +// parseOne parses a character (grapheme cluster). If isEscape is true, it means that the character +// is part of an escape sequence, and as such should not be printed verbatim. Otherwise, it's not an +// escape sequence. +func (ei *escapeInterpreter) parseOne(ch []byte) (isEscape bool, err error) { // Sanity checks if len(ei.csiParam) > 20 { return false, errCSITooLong @@ -126,21 +127,21 @@ func (ei *escapeInterpreter) parseOne(ch rune) (isEscape bool, err error) { return false, errCSITooLong } - ei.curch = ch + ei.curch = string(ch) switch ei.state { case stateNone: - if ch == 0x1b { + if characterEquals(ch, 0x1b) { ei.state = stateEscape return true, nil } return false, nil case stateEscape: - switch ch { - case '[': + switch { + case characterEquals(ch, '['): ei.state = stateCSI return true, nil - case ']': + case characterEquals(ch, ']'): ei.state = stateOSC return true, nil default: @@ -148,11 +149,11 @@ func (ei *escapeInterpreter) parseOne(ch rune) (isEscape bool, err error) { } case stateCSI: switch { - case ch >= '0' && ch <= '9': + case len(ch) == 1 && ch[0] >= '0' && ch[0] <= '9': ei.csiParam = append(ei.csiParam, "") - case ch == 'm': + case characterEquals(ch, 'm'): ei.csiParam = append(ei.csiParam, "0") - case ch == 'K': + case characterEquals(ch, 'K'): // fall through default: return false, errCSIParseError @@ -161,13 +162,13 @@ func (ei *escapeInterpreter) parseOne(ch rune) (isEscape bool, err error) { fallthrough case stateParams: switch { - case ch >= '0' && ch <= '9': + case len(ch) == 1 && ch[0] >= '0' && ch[0] <= '9': ei.csiParam[len(ei.csiParam)-1] += string(ch) return true, nil - case ch == ';': + case characterEquals(ch, ';'): ei.csiParam = append(ei.csiParam, "") return true, nil - case ch == 'm': + case characterEquals(ch, 'm'): if err := ei.outputCSI(); err != nil { return false, errCSIParseError } @@ -175,7 +176,7 @@ func (ei *escapeInterpreter) parseOne(ch rune) (isEscape bool, err error) { ei.state = stateNone ei.csiParam = nil return true, nil - case ch == 'K': + case characterEquals(ch, 'K'): p := 0 if len(ei.csiParam) != 0 && ei.csiParam[0] != "" { p, err = strconv.Atoi(ei.csiParam[0]) @@ -198,44 +199,44 @@ func (ei *escapeInterpreter) parseOne(ch rune) (isEscape bool, err error) { return false, errCSIParseError } case stateOSC: - if ch == '8' { + if characterEquals(ch, '8') { ei.state = stateOSCWaitForParams - ei.hyperlink = "" + ei.hyperlink.Reset() return true, nil } ei.state = stateOSCSkipUnknown return true, nil case stateOSCWaitForParams: - if ch != ';' { + if !characterEquals(ch, ';') { return true, errOSCParseError } ei.state = stateOSCParams return true, nil case stateOSCParams: - if ch == ';' { + if characterEquals(ch, ';') { ei.state = stateOSCHyperlink } return true, nil case stateOSCHyperlink: - switch ch { - case 0x07: + switch { + case characterEquals(ch, 0x07): ei.state = stateNone - case 0x1b: + case characterEquals(ch, 0x1b): ei.state = stateOSCEndEscape default: - ei.hyperlink += string(ch) + ei.hyperlink.Write(ch) } return true, nil case stateOSCEndEscape: ei.state = stateNone return true, nil case stateOSCSkipUnknown: - switch ch { - case 0x07: + switch { + case characterEquals(ch, 0x07): ei.state = stateNone - case 0x1b: + case characterEquals(ch, 0x1b): ei.state = stateOSCEndEscape } return true, nil diff --git a/escape_test.go b/escape_test.go index a4737951..b217cc07 100644 --- a/escape_test.go +++ b/escape_test.go @@ -10,7 +10,7 @@ func TestParseOne(t *testing.T) { var ei *escapeInterpreter ei = newEscapeInterpreter(OutputNormal) - isEscape, err := ei.parseOne('a') + isEscape, err := ei.parseOne([]byte{'a'}) assert.Equal(t, false, isEscape) assert.NoError(t, err) @@ -131,8 +131,8 @@ func TestParseOneColours(t *testing.T) { } func parseEscRunes(t *testing.T, ei *escapeInterpreter, runes string) { - for _, r := range runes { - isEscape, err := ei.parseOne(r) + for _, b := range []byte(runes) { + isEscape, err := ei.parseOne([]byte{b}) assert.Equal(t, true, isEscape) assert.NoError(t, err) } diff --git a/go.mod b/go.mod index 7277243c..0f0841d4 100644 --- a/go.mod +++ b/go.mod @@ -1,11 +1,21 @@ module github.com/jesseduffield/gocui -go 1.12 +go 1.25 require ( - github.com/gdamore/tcell/v2 v2.8.0 + github.com/gdamore/tcell/v2 v2.13.5 github.com/go-errors/errors v1.0.2 - github.com/mattn/go-runewidth v0.0.16 - github.com/rivo/uniseg v0.4.7 // indirect + github.com/rivo/uniseg v0.4.7 github.com/stretchr/testify v1.7.0 ) + +require ( + github.com/davecgh/go-spew v1.1.0 // indirect + github.com/gdamore/encoding v1.0.1 // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + golang.org/x/sys v0.39.0 // indirect + golang.org/x/term v0.38.0 // indirect + golang.org/x/text v0.32.0 // indirect + gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c // indirect +) diff --git a/go.sum b/go.sum index 0be2a19c..fd70acb3 100644 --- a/go.sum +++ b/go.sum @@ -2,19 +2,14 @@ github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw= github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo= -github.com/gdamore/tcell/v2 v2.8.0 h1:IDclow1j6kKpU/gOhjmc+7Pj5Dxnukb74pfKN4Cxrfg= -github.com/gdamore/tcell/v2 v2.8.0/go.mod h1:bj8ori1BG3OYMjmb3IklZVWfZUJ1UBQt9JXrOCOhGWw= +github.com/gdamore/tcell/v2 v2.13.5 h1:YvWYCSr6gr2Ovs84dXbZLjDuOfQchhj8buOEqY52rpA= +github.com/gdamore/tcell/v2 v2.13.5/go.mod h1:+Wfe208WDdB7INEtCsNrAN6O2m+wsTPk1RAovjaILlo= github.com/go-errors/errors v1.0.2 h1:xMxH9j2fNg/L4hLn/4y3M0IUsn0M6Wbu/Uh9QlOfBh4= github.com/go-errors/errors v1.0.2/go.mod h1:psDX2osz5VnTOnFWbDeWwS7yejl+uV3FEWEp4lssFEs= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= -github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= -github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= -github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= -github.com/rivo/uniseg v0.4.3/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -23,67 +18,39 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= -golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= -golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= -golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= -golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= -golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= -golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= -golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= +golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= -golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= -golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= -golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= -golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= -golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg= -golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek= +golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q= +golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= -golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= -golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= +golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= -golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= -golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/gui.go b/gui.go index dac90169..39ba4743 100644 --- a/gui.go +++ b/gui.go @@ -8,13 +8,14 @@ import ( "context" standardErrors "errors" "runtime" + "slices" "strings" "sync" "time" "github.com/gdamore/tcell/v2" "github.com/go-errors/errors" - "github.com/mattn/go-runewidth" + "github.com/rivo/uniseg" ) // OutputMode represents an output mode, which determines how colors @@ -189,9 +190,9 @@ type Gui struct { OnSearchEscape func() error // these keys must either be of type Key of rune - SearchEscapeKey interface{} - NextSearchMatchKey interface{} - PrevSearchMatchKey interface{} + SearchEscapeKey any + NextSearchMatchKey any + PrevSearchMatchKey any ErrorHandler func(error) error @@ -300,25 +301,16 @@ func (g *Gui) Size() (x, y int) { // SetRune writes a rune at the given point, relative to the top-left // corner of the terminal. It checks if the position is valid and applies // the given colors. +// Should only be used if you know that the given rune is not part of a grapheme cluster. func (g *Gui) SetRune(x, y int, ch rune, fgColor, bgColor Attribute) error { if x < 0 || y < 0 || x >= g.maxX || y >= g.maxY { // swallowing error because it's not that big of a deal return nil } - tcellSetCell(x, y, ch, fgColor, bgColor, g.outputMode) + tcellSetCell(x, y, string(ch), fgColor, bgColor, g.outputMode) return nil } -// Rune returns the rune contained in the cell at the given position. -// It checks if the position is valid. -func (g *Gui) Rune(x, y int) (rune, error) { - if x < 0 || y < 0 || x >= g.maxX || y >= g.maxY { - return ' ', errors.New("invalid point") - } - c, _, _, _ := Screen.GetContent(x, y) - return c, nil -} - // SetView creates a new view with its top-left corner at (x0, y0) // and the bottom-right one at (x1, y1). If a view with the same name // already exists, its dimensions are updated; otherwise, the error @@ -554,7 +546,7 @@ func (g *Gui) CurrentView() *View { // It behaves differently on different platforms. Somewhere it doesn't register Alt key press, // on others it might report Ctrl as Alt. It's not consistent and therefore it's not recommended // to use with mouse keys. -func (g *Gui) SetKeybinding(viewname string, key interface{}, mod Modifier, handler func(*Gui, *View) error) error { +func (g *Gui) SetKeybinding(viewname string, key any, mod Modifier, handler func(*Gui, *View) error) error { var kb *keybinding k, ch, err := getKey(key) @@ -572,7 +564,7 @@ func (g *Gui) SetKeybinding(viewname string, key interface{}, mod Modifier, hand } // DeleteKeybinding deletes a keybinding. -func (g *Gui) DeleteKeybinding(viewname string, key interface{}, mod Modifier) error { +func (g *Gui) DeleteKeybinding(viewname string, key any, mod Modifier) error { k, ch, err := getKey(key) if err != nil { return err @@ -623,10 +615,8 @@ func (g *Gui) SetViewClickBinding(binding *ViewMouseBinding) error { // BlackListKeybinding adds a keybinding to the blacklist func (g *Gui) BlacklistKeybinding(k Key) error { - for _, j := range g.blacklist { - if j == k { - return ErrAlreadyBlacklisted - } + if slices.Contains(g.blacklist, k) { + return ErrAlreadyBlacklisted } g.blacklist = append(g.blacklist, k) return nil @@ -653,7 +643,7 @@ func (g *Gui) SetOpenHyperlinkFunc(openHyperlinkFunc func(string, string) error) // getKey takes an empty interface with a key and returns the corresponding // typed Key or rune. -func getKey(key interface{}) (Key, rune, error) { +func getKey(key any) (Key, rune, error) { switch t := key.(type) { case nil: // Ignore keybinding if `nil` return 0, 0, nil @@ -1103,7 +1093,7 @@ func (g *Gui) drawTitle(v *View, fgColor, bgColor Attribute) error { if err := g.SetRune(x, v.y0, ch, fgColor, bgColor); err != nil { return err } - x += runewidth.RuneWidth(ch) + x += uniseg.StringWidth(string(ch)) } for i, ch := range str { if x < 0 { @@ -1128,7 +1118,7 @@ func (g *Gui) drawTitle(v *View, fgColor, bgColor Attribute) error { if err := g.SetRune(x, v.y0, ch, currentFgColor, currentBgColor); err != nil { return err } - x += runewidth.RuneWidth(ch) + x += uniseg.StringWidth(string(ch)) } return nil } @@ -1139,7 +1129,7 @@ func (g *Gui) drawSubtitle(v *View, fgColor, bgColor Attribute) error { return nil } - start := v.x1 - 5 - runewidth.StringWidth(v.Subtitle) + start := v.x1 - 5 - uniseg.StringWidth(v.Subtitle) if start < v.x0 { return nil } @@ -1151,7 +1141,7 @@ func (g *Gui) drawSubtitle(v *View, fgColor, bgColor Attribute) error { if err := g.SetRune(x, v.y0, ch, fgColor, bgColor); err != nil { return err } - x += runewidth.RuneWidth(ch) + x += uniseg.StringWidth(string(ch)) } return nil } @@ -1168,7 +1158,7 @@ func (g *Gui) drawListFooter(v *View, fgColor, bgColor Attribute) error { return nil } - start := v.x1 - 1 - runewidth.StringWidth(message) + start := v.x1 - 1 - uniseg.StringWidth(message) if start < v.x0 { return nil } @@ -1180,7 +1170,7 @@ func (g *Gui) drawListFooter(v *View, fgColor, bgColor Attribute) error { if err := g.SetRune(x, v.y1, ch, fgColor, bgColor); err != nil { return err } - x += runewidth.RuneWidth(ch) + x += uniseg.StringWidth(string(ch)) } return nil } @@ -1369,13 +1359,13 @@ func (g *Gui) onKey(ev *GocuiEvent) error { newCy = newY - v.oy } - lastCharForLine := len(v.lines[newY]) - for lastCharForLine > 0 && v.lines[newY][lastCharForLine-1].chr == 0 { - lastCharForLine-- + visibleLineWidth := 0 + for _, c := range v.lines[newY] { + visibleLineWidth += c.width } - if lastCharForLine < newX { - newX = lastCharForLine - newCx = lastCharForLine - v.ox + if visibleLineWidth < newX { + newX = visibleLineWidth + newCx = visibleLineWidth - v.ox } } if !IsMouseScrollKey(ev.Key) { @@ -1485,7 +1475,7 @@ func (g *Gui) execMouseKeybindings(view *View, ev *GocuiEvent, opts ViewMouseBin return false, nil } -func IsMouseKey(key interface{}) bool { +func IsMouseKey(key any) bool { switch key { case MouseLeft, @@ -1502,7 +1492,7 @@ func IsMouseKey(key interface{}) bool { } } -func IsMouseScrollKey(key interface{}) bool { +func IsMouseScrollKey(key any) bool { switch key { case MouseWheelUp, @@ -1640,12 +1630,7 @@ func (g *Gui) StartTicking(ctx context.Context) { // isBlacklisted reports whether the key is blacklisted func (g *Gui) isBlacklisted(k Key) bool { - for _, j := range g.blacklist { - if j == k { - return true - } - } - return false + return slices.Contains(g.blacklist, k) } func (g *Gui) Suspend() error { @@ -1699,13 +1684,13 @@ func (g *Gui) Snapshot() string { builder := &strings.Builder{} - for y := 0; y < height; y++ { + for y := range height { for x := 0; x < width; x++ { - char, _, _, charWidth := g.screen.GetContent(x, y) + char, _, charWidth := g.screen.Get(x, y) if charWidth == 0 { continue } - builder.WriteRune(char) + builder.WriteString(char) if charWidth > 1 { x += charWidth - 1 } diff --git a/gui_others.go b/gui_others.go index f0de7822..4becd505 100644 --- a/gui_others.go +++ b/gui_others.go @@ -3,7 +3,6 @@ // license that can be found in the LICENSE file. //go:build !windows -// +build !windows package gocui diff --git a/gui_windows.go b/gui_windows.go index 56c54570..1934a40a 100644 --- a/gui_windows.go +++ b/gui_windows.go @@ -3,7 +3,6 @@ // license that can be found in the LICENSE file. //go:build windows -// +build windows package gocui diff --git a/keybinding.go b/keybinding.go index 0d2cecc6..0ad5dbe5 100644 --- a/keybinding.go +++ b/keybinding.go @@ -28,7 +28,7 @@ type keybinding struct { // Parse takes the input string and extracts the keybinding. // Returns a Key / rune, a Modifier and an error. -func Parse(input string) (interface{}, Modifier, error) { +func Parse(input string) (any, Modifier, error) { if len(input) == 1 { _, r, err := getKey(rune(input[0])) if err != nil { @@ -40,8 +40,8 @@ func Parse(input string) (interface{}, Modifier, error) { var modifier Modifier cleaned := make([]string, 0) - tokens := strings.Split(input, "+") - for _, t := range tokens { + tokens := strings.SplitSeq(input, "+") + for t := range tokens { normalized := strings.Title(strings.ToLower(t)) if t == "Alt" { modifier = ModAlt @@ -59,8 +59,8 @@ func Parse(input string) (interface{}, Modifier, error) { } // ParseAll takes an array of strings and returns a map of all keybindings. -func ParseAll(input []string) (map[interface{}]Modifier, error) { - ret := make(map[interface{}]Modifier) +func ParseAll(input []string) (map[any]Modifier, error) { + ret := make(map[any]Modifier) for _, i := range input { k, m, err := Parse(i) if err != nil { @@ -73,7 +73,7 @@ func ParseAll(input []string) (map[interface{}]Modifier, error) { // MustParse takes the input string and returns a Key / rune and a Modifier. // It will panic if any error occured. -func MustParse(input string) (interface{}, Modifier) { +func MustParse(input string) (any, Modifier) { k, m, err := Parse(input) if err != nil { panic(err) @@ -83,7 +83,7 @@ func MustParse(input string) (interface{}, Modifier) { // MustParseAll takes an array of strings and returns a map of all keybindings. // It will panic if any error occured. -func MustParseAll(input []string) map[interface{}]Modifier { +func MustParseAll(input []string) map[any]Modifier { result, err := ParseAll(input) if err != nil { panic(err) @@ -103,7 +103,7 @@ func newKeybinding(viewname string, key Key, ch rune, mod Modifier, handler func return kb } -func eventMatchesKey(ev *GocuiEvent, key interface{}) bool { +func eventMatchesKey(ev *GocuiEvent, key any) bool { // assuming ModNone for now if ev.Mod != ModNone { return false diff --git a/loader.go b/loader.go index 0aad5ae8..5f76db7b 100644 --- a/loader.go +++ b/loader.go @@ -11,7 +11,7 @@ func (v *View) loaderLines() [][]cell { } else { duplicate[i] = make([]cell, len(v.lines[i])+2) copy(duplicate[i], v.lines[i]) - duplicate[i][len(duplicate[i])-2] = cell{chr: ' '} + duplicate[i][len(duplicate[i])-2] = cell{chr: " "} duplicate[i][len(duplicate[i])-1] = Loader() } } @@ -21,13 +21,11 @@ func (v *View) loaderLines() [][]cell { // Loader can show a loading animation func Loader() cell { - characters := "|/-\\" + frames := []string{"|", "/", "-", "\\"} now := time.Now() nanos := now.UnixNano() - index := nanos / 50000000 % int64(len(characters)) - str := characters[index : index+1] - chr := []rune(str)[0] + index := nanos / 50000000 % int64(len(frames)) return cell{ - chr: chr, + chr: frames[index], } } diff --git a/tcell_driver.go b/tcell_driver.go index 4199f7ab..6e9c12b4 100644 --- a/tcell_driver.go +++ b/tcell_driver.go @@ -6,7 +6,6 @@ package gocui import ( "github.com/gdamore/tcell/v2" - "github.com/mattn/go-runewidth" ) // We probably don't want this being a global variable for YOLO for now @@ -54,7 +53,6 @@ var runeReplacements = map[rune]string{ // tcellInit initializes tcell screen for use. func (g *Gui) tcellInit(runeReplacements map[rune]string) error { - runewidth.DefaultCondition.EastAsianWidth = false tcell.SetEncodingFallback(tcell.EncodingFallbackASCII) if s, e := tcell.NewScreen(); e != nil { @@ -97,10 +95,10 @@ func (g *Gui) tcellInitSimulation(width int, height int) error { } // tcellSetCell sets the character cell at a given location to the given -// content (rune) and attributes using provided OutputMode -func tcellSetCell(x, y int, ch rune, fg, bg Attribute, outputMode OutputMode) { +// content (grapheme cluster) and attributes using provided OutputMode +func tcellSetCell(x, y int, ch string, fg, bg Attribute, outputMode OutputMode) { st := getTcellStyle(oldStyle{fg: fg, bg: bg, outputMode: outputMode}) - Screen.SetContent(x, y, ch, nil, st) + Screen.Put(x, y, ch, st) } // getTcellStyle creates tcell.Style from Attributes diff --git a/text_area.go b/text_area.go index 1194b272..da5562f8 100644 --- a/text_area.go +++ b/text_area.go @@ -2,9 +2,10 @@ package gocui import ( "regexp" + "slices" "strings" - "github.com/mattn/go-runewidth" + "github.com/rivo/uniseg" ) const ( @@ -12,55 +13,114 @@ const ( WORD_SEPARATORS = "*?_+-.[]~=/&;!#$%^(){}<>" ) -type CursorMapping struct { - Orig int - Wrapped int +type TextAreaCell struct { + char string // string because it could be a multi-rune grapheme cluster + width int + x, y int // cell coordinates + contentIndex int // byte index into the original content +} + +// returns the cursor x,y position after this cell +func (c *TextAreaCell) nextCursorXY() (int, int) { + if c.char == "\n" { + return 0, c.y + 1 + } + return c.x + c.width, c.y } type TextArea struct { - content []rune - wrappedContent []rune - cursorMapping []CursorMapping - cursor int - overwrite bool - clipboard string - AutoWrap bool - AutoWrapWidth int -} - -func AutoWrapContent(content []rune, autoWrapWidth int) ([]rune, []CursorMapping) { - estimatedNumberOfSoftLineBreaks := len(content) / autoWrapWidth - cursorMapping := make([]CursorMapping, 0, estimatedNumberOfSoftLineBreaks) - wrappedContent := make([]rune, 0, len(content)+estimatedNumberOfSoftLineBreaks) + content string + cells []TextAreaCell + cursor int // position in content, as an index into the byte array + overwrite bool + clipboard string + AutoWrap bool + AutoWrapWidth int +} + +func stringToTextAreaCells(str string) []TextAreaCell { + result := make([]TextAreaCell, 0, len(str)) + + contentIndex := 0 + state := -1 + for len(str) > 0 { + var c string + var w int + c, str, w, state = uniseg.FirstGraphemeClusterInString(str, state) + // only set char, width, and contentIndex; x and y will be set later + result = append(result, TextAreaCell{char: c, width: w, contentIndex: contentIndex}) + contentIndex += len(c) + } + return result +} + +// Returns the indices in content where soft line breaks occur due to auto-wrapping to the given width. +func AutoWrapContent(content string, autoWrapWidth int) []int { + _, softLineBreakIndices := contentToCells(content, autoWrapWidth) + return softLineBreakIndices +} + +func contentToCells(content string, autoWrapWidth int) ([]TextAreaCell, []int) { + estimatedNumberOfSoftLineBreaks := 0 + if autoWrapWidth > 0 { + estimatedNumberOfSoftLineBreaks = len(content) / autoWrapWidth + } + softLineBreakIndices := make([]int, 0, estimatedNumberOfSoftLineBreaks) + result := make([]TextAreaCell, 0, len(content)+estimatedNumberOfSoftLineBreaks) startOfLine := 0 + currentLineWidth := 0 indexOfLastWhitespace := -1 var footNoteMatcher footNoteMatcher - for currentPos, r := range content { - if r == '\n' { - wrappedContent = append(wrappedContent, content[startOfLine:currentPos+1]...) + cells := stringToTextAreaCells(content) + y := 0 + + appendCellsSinceLineStart := func(to int) { + x := 0 + for i := startOfLine; i < to; i++ { + cells[i].x = x + cells[i].y = y + x += cells[i].width + } + + result = append(result, cells[startOfLine:to]...) + } + + for currentPos, c := range cells { + if c.char == "\n" { + appendCellsSinceLineStart(currentPos + 1) + y++ startOfLine = currentPos + 1 indexOfLastWhitespace = -1 + currentLineWidth = 0 footNoteMatcher.reset() } else { - if r == ' ' && !footNoteMatcher.isFootNote() { + currentLineWidth += c.width + if c.char == " " && !footNoteMatcher.isFootNote() { indexOfLastWhitespace = currentPos + 1 - } else if currentPos-startOfLine >= autoWrapWidth && indexOfLastWhitespace >= 0 { + } else if autoWrapWidth > 0 && currentLineWidth > autoWrapWidth && indexOfLastWhitespace >= 0 { wrapAt := indexOfLastWhitespace - wrappedContent = append(wrappedContent, content[startOfLine:wrapAt]...) - wrappedContent = append(wrappedContent, '\n') - cursorMapping = append(cursorMapping, CursorMapping{wrapAt, len(wrappedContent)}) + appendCellsSinceLineStart(wrapAt) + contentIndex := cells[wrapAt].contentIndex + y++ + result = append(result, TextAreaCell{char: "\n", width: 1, contentIndex: contentIndex, x: 0, y: y}) + softLineBreakIndices = append(softLineBreakIndices, contentIndex) startOfLine = wrapAt indexOfLastWhitespace = -1 + currentLineWidth = 0 + for _, c1 := range cells[startOfLine : currentPos+1] { + currentLineWidth += c1.width + } footNoteMatcher.reset() } - footNoteMatcher.addRune(r) + + footNoteMatcher.addCharacter(c.char) } } - wrappedContent = append(wrappedContent, content[startOfLine:]...) + appendCellsSinceLineStart(len(cells)) - return wrappedContent, cursorMapping + return result, softLineBreakIndices } var footNoteRe = regexp.MustCompile(`^\[\d+\]:\s*$`) @@ -70,20 +130,20 @@ type footNoteMatcher struct { didFailToMatch bool } -func (self *footNoteMatcher) addRune(r rune) { +func (self *footNoteMatcher) addCharacter(chr string) { if self.didFailToMatch { // don't bother tracking the rune if we know it can't possibly match any more return } - if self.lineStr.Len() == 0 && r != '[' { + if self.lineStr.Len() == 0 && chr != "[" { // fail early if the first rune of a line isn't a '['; this is mainly to avoid a (possibly // expensive) regex match self.didFailToMatch = true return } - self.lineStr.WriteRune(r) + self.lineStr.WriteString(chr) } func (self *footNoteMatcher) isFootNote() bool { @@ -107,26 +167,29 @@ func (self *footNoteMatcher) reset() { self.didFailToMatch = false } -func (self *TextArea) autoWrapContent() { - if self.AutoWrap { - self.wrappedContent, self.cursorMapping = AutoWrapContent(self.content, self.AutoWrapWidth) - } else { - self.wrappedContent, self.cursorMapping = self.content, []CursorMapping{} +func (self *TextArea) updateCells() { + width := self.AutoWrapWidth + if !self.AutoWrap { + width = -1 } + + self.cells, _ = contentToCells(self.content, width) } -func (self *TextArea) TypeRune(r rune) { +func (self *TextArea) typeCharacter(ch string) { + widthToDelete := 0 if self.overwrite && !self.atEnd() { - self.content[self.cursor] = r - } else { - self.content = append( - self.content[:self.cursor], - append([]rune{r}, self.content[self.cursor:]...)..., - ) + s, _, _, _ := uniseg.FirstGraphemeClusterInString(self.content[self.cursor:], -1) + widthToDelete = len(s) } - self.autoWrapContent() - self.cursor++ + self.content = self.content[:self.cursor] + ch + self.content[self.cursor+widthToDelete:] + self.cursor += len(ch) +} + +func (self *TextArea) TypeCharacter(ch string) { + self.typeCharacter(ch) + self.updateCells() } func (self *TextArea) BackSpaceChar() { @@ -134,9 +197,14 @@ func (self *TextArea) BackSpaceChar() { return } - self.content = append(self.content[:self.cursor-1], self.content[self.cursor:]...) - self.autoWrapContent() - self.cursor-- + cellCursor := self.contentCursorToCellCursor(self.cursor) + widthToDelete := len(self.cells[cellCursor-1].char) + + oldCursor := self.cursor + self.cursor -= widthToDelete + self.content = self.content[:self.cursor] + self.content[oldCursor:] + + self.updateCells() } func (self *TextArea) DeleteChar() { @@ -144,8 +212,10 @@ func (self *TextArea) DeleteChar() { return } - self.content = append(self.content[:self.cursor], self.content[self.cursor+1:]...) - self.autoWrapContent() + s, _, _, _ := uniseg.FirstGraphemeClusterInString(self.content[self.cursor:], -1) + widthToDelete := len(s) + self.content = self.content[:self.cursor] + self.content[self.cursor+widthToDelete:] + self.updateCells() } func (self *TextArea) MoveCursorLeft() { @@ -153,7 +223,8 @@ func (self *TextArea) MoveCursorLeft() { return } - self.cursor-- + cellCursor := self.contentCursorToCellCursor(self.cursor) + self.cursor -= len(self.cells[cellCursor-1].char) } func (self *TextArea) MoveCursorRight() { @@ -161,31 +232,38 @@ func (self *TextArea) MoveCursorRight() { return } - self.cursor++ + s, _, _, _ := uniseg.FirstGraphemeClusterInString(self.content[self.cursor:], -1) + self.cursor += len(s) } -func (self *TextArea) MoveLeftWord() { +func (self *TextArea) newCursorForMoveLeftWord() int { if self.cursor == 0 { - return + return 0 } if self.atLineStart() { - self.cursor-- - return + return self.cursor - 1 } - for !self.atLineStart() && strings.ContainsRune(WHITESPACES, self.content[self.cursor-1]) { - self.cursor-- + cellCursor := self.contentCursorToCellCursor(self.cursor) + for cellCursor > 0 && (self.isSoftLineBreak(cellCursor-1) || strings.Contains(WHITESPACES, self.cells[cellCursor-1].char)) { + cellCursor-- } separators := false - for !self.atLineStart() && strings.ContainsRune(WORD_SEPARATORS, self.content[self.cursor-1]) { - self.cursor-- + for cellCursor > 0 && strings.Contains(WORD_SEPARATORS, self.cells[cellCursor-1].char) { + cellCursor-- separators = true } if !separators { - for !self.atLineStart() && !strings.ContainsRune(WHITESPACES+WORD_SEPARATORS, self.content[self.cursor-1]) { - self.cursor-- + for cellCursor > 0 && self.cells[cellCursor-1].char != "\n" && !strings.Contains(WHITESPACES+WORD_SEPARATORS, self.cells[cellCursor-1].char) { + cellCursor-- } } + + return self.cellCursorToContentCursor(cellCursor) +} + +func (self *TextArea) MoveLeftWord() { + self.cursor = self.newCursorForMoveLeftWord() } func (self *TextArea) MoveRightWord() { @@ -197,19 +275,22 @@ func (self *TextArea) MoveRightWord() { return } - for !self.atLineEnd() && strings.ContainsRune(WHITESPACES, self.content[self.cursor]) { - self.cursor++ + cellCursor := self.contentCursorToCellCursor(self.cursor) + for cellCursor < len(self.cells) && (self.isSoftLineBreak(cellCursor) || strings.Contains(WHITESPACES, self.cells[cellCursor].char)) { + cellCursor++ } separators := false - for !self.atLineEnd() && strings.ContainsRune(WORD_SEPARATORS, self.content[self.cursor]) { - self.cursor++ + for cellCursor < len(self.cells) && strings.Contains(WORD_SEPARATORS, self.cells[cellCursor].char) { + cellCursor++ separators = true } if !separators { - for !self.atLineEnd() && !strings.ContainsRune(WHITESPACES+WORD_SEPARATORS, self.content[self.cursor]) { - self.cursor++ + for cellCursor < len(self.cells) && self.cells[cellCursor].char != "\n" && !strings.Contains(WHITESPACES+WORD_SEPARATORS, self.cells[cellCursor].char) { + cellCursor++ } } + + self.cursor = self.cellCursorToContentCursor(cellCursor) } func (self *TextArea) MoveCursorUp() { @@ -223,11 +304,15 @@ func (self *TextArea) MoveCursorDown() { } func (self *TextArea) GetContent() string { - return string(self.wrappedContent) + var b strings.Builder + for _, c := range self.cells { + b.WriteString(c.char) + } + return b.String() } func (self *TextArea) GetUnwrappedContent() string { - return string(self.content) + return self.content } func (self *TextArea) ToggleOverwrite() { @@ -246,9 +331,9 @@ func (self *TextArea) DeleteToStartOfLine() { return } - self.content = append(self.content[:self.cursor-1], self.content[self.cursor:]...) + self.content = self.content[:self.cursor-1] + self.content[self.cursor:] self.cursor-- - self.autoWrapContent() + self.updateCells() return } @@ -263,9 +348,9 @@ func (self *TextArea) DeleteToStartOfLine() { // otherwise, you delete everything up to the start of the current line, without // deleting the newline character newlineIndex := self.closestNewlineOnLeft() - self.clipboard = string(self.content[newlineIndex+1 : self.cursor]) - self.content = append(self.content[:newlineIndex+1], self.content[self.cursor:]...) - self.autoWrapContent() + self.clipboard = self.content[newlineIndex+1 : self.cursor] + self.content = self.content[:newlineIndex+1] + self.content[self.cursor:] + self.updateCells() self.cursor = newlineIndex + 1 } @@ -276,8 +361,8 @@ func (self *TextArea) DeleteToEndOfLine() { // if we're at the end of the line, delete just the newline character if self.atLineEnd() { - self.content = append(self.content[:self.cursor], self.content[self.cursor+1:]...) - self.autoWrapContent() + self.content = self.content[:self.cursor] + self.content[self.cursor+1:] + self.updateCells() return } @@ -290,9 +375,9 @@ func (self *TextArea) DeleteToEndOfLine() { } lineEndIndex := self.closestNewlineOnRight() - self.clipboard = string(self.content[self.cursor:lineEndIndex]) - self.content = append(self.content[:self.cursor], self.content[lineEndIndex:]...) - self.autoWrapContent() + self.clipboard = self.content[self.cursor:lineEndIndex] + self.content = self.content[:self.cursor] + self.content[lineEndIndex:] + self.updateCells() } func (self *TextArea) GoToStartOfLine() { @@ -300,28 +385,30 @@ func (self *TextArea) GoToStartOfLine() { return } - // otherwise, you delete everything up to the start of the current line, without - // deleting the newline character newlineIndex := self.closestNewlineOnLeft() self.cursor = newlineIndex + 1 } func (self *TextArea) closestNewlineOnLeft() int { - wrappedCursor := self.origCursorToWrappedCursor(self.cursor) + cellCursor := self.contentCursorToCellCursor(self.cursor) - newlineIndex := -1 + newlineCellIndex := -1 - for i, r := range self.wrappedContent[0:wrappedCursor] { - if r == '\n' { - newlineIndex = i + for i, c := range self.cells[0:cellCursor] { + if c.char == "\n" { + newlineCellIndex = i } } - unwrappedNewlineIndex := self.wrappedCursorToOrigCursor(newlineIndex) - if unwrappedNewlineIndex >= 0 && self.content[unwrappedNewlineIndex] != '\n' { - unwrappedNewlineIndex-- + if newlineCellIndex == -1 { + return -1 } - return unwrappedNewlineIndex + + newlineContentIndex := self.cells[newlineCellIndex].contentIndex + if self.content[newlineContentIndex] != '\n' { + newlineContentIndex-- + } + return newlineContentIndex } func (self *TextArea) GoToEndOfLine() { @@ -335,11 +422,11 @@ func (self *TextArea) GoToEndOfLine() { } func (self *TextArea) closestNewlineOnRight() int { - wrappedCursor := self.origCursorToWrappedCursor(self.cursor) + cellCursor := self.contentCursorToCellCursor(self.cursor) - for i, r := range self.wrappedContent[wrappedCursor:] { - if r == '\n' { - return self.wrappedCursorToOrigCursor(wrappedCursor + i) + for i, c := range self.cells[cellCursor:] { + if c.char == "\n" { + return self.cellCursorToContentCursor(cellCursor + i) } } @@ -361,10 +448,15 @@ func (self *TextArea) atLineStart() bool { (len(self.content) > self.cursor-1 && self.content[self.cursor-1] == '\n') } +func (self *TextArea) isSoftLineBreak(cellCursor int) bool { + cell := self.cells[cellCursor] + return cell.char == "\n" && self.content[cell.contentIndex] != '\n' +} + func (self *TextArea) atSoftLineStart() bool { - wrappedCursor := self.origCursorToWrappedCursor(self.cursor) - return wrappedCursor == 0 || - (len(self.wrappedContent) > wrappedCursor-1 && self.wrappedContent[wrappedCursor-1] == '\n') + cellCursor := self.contentCursorToCellCursor(self.cursor) + return cellCursor == 0 || + (len(self.cells) > cellCursor-1 && self.cells[cellCursor-1].char == "\n") } func (self *TextArea) atLineEnd() bool { @@ -373,91 +465,61 @@ func (self *TextArea) atLineEnd() bool { } func (self *TextArea) atSoftLineEnd() bool { - wrappedCursor := self.origCursorToWrappedCursor(self.cursor) - return wrappedCursor == len(self.wrappedContent) || - (len(self.wrappedContent) > wrappedCursor+1 && self.wrappedContent[wrappedCursor+1] == '\n') + cellCursor := self.contentCursorToCellCursor(self.cursor) + return cellCursor == len(self.cells) || + (len(self.cells) > cellCursor+1 && self.cells[cellCursor+1].char == "\n") } func (self *TextArea) BackSpaceWord() { - if self.cursor == 0 { - return - } - if self.atLineStart() { - self.BackSpaceChar() + newCursor := self.newCursorForMoveLeftWord() + if newCursor == self.cursor { return } - right := self.cursor - for !self.atLineStart() && strings.ContainsRune(WHITESPACES, self.content[self.cursor-1]) { - self.cursor-- - } - separators := false - for !self.atLineStart() && strings.ContainsRune(WORD_SEPARATORS, self.content[self.cursor-1]) { - self.cursor-- - separators = true + clipboard := self.content[newCursor:self.cursor] + if clipboard != "\n" { + self.clipboard = clipboard } - if !separators { - for !self.atLineStart() && !strings.ContainsRune(WHITESPACES+WORD_SEPARATORS, self.content[self.cursor-1]) { - self.cursor-- - } - } - - self.clipboard = string(self.content[self.cursor:right]) - self.content = append(self.content[:self.cursor], self.content[right:]...) - self.autoWrapContent() + self.content = self.content[:newCursor] + self.content[self.cursor:] + self.cursor = newCursor + self.updateCells() } func (self *TextArea) Yank() { self.TypeString(self.clipboard) } -func origCursorToWrappedCursor(origCursor int, cursorMapping []CursorMapping) int { - prevMapping := CursorMapping{0, 0} - for _, mapping := range cursorMapping { - if origCursor < mapping.Orig { - break - } - prevMapping = mapping +func (self *TextArea) contentCursorToCellCursor(origCursor int) int { + idx, _ := slices.BinarySearchFunc(self.cells, origCursor, func(cell TextAreaCell, cursor int) int { + return cell.contentIndex - cursor + }) + for idx < len(self.cells)-1 && self.cells[idx+1].contentIndex == origCursor { + idx++ } - - return origCursor + prevMapping.Wrapped - prevMapping.Orig -} - -func (self *TextArea) origCursorToWrappedCursor(origCursor int) int { - return origCursorToWrappedCursor(origCursor, self.cursorMapping) + return idx } -func wrappedCursorToOrigCursor(wrappedCursor int, cursorMapping []CursorMapping) int { - prevMapping := CursorMapping{0, 0} - for _, mapping := range cursorMapping { - if wrappedCursor < mapping.Wrapped { - break - } - prevMapping = mapping +func (self *TextArea) cellCursorToContentCursor(cellCursor int) int { + if cellCursor >= len(self.cells) { + return len(self.content) } - return wrappedCursor + prevMapping.Orig - prevMapping.Wrapped -} - -func (self *TextArea) wrappedCursorToOrigCursor(wrappedCursor int) int { - return wrappedCursorToOrigCursor(wrappedCursor, self.cursorMapping) + return self.cells[cellCursor].contentIndex } func (self *TextArea) GetCursorXY() (int, int) { - cursorX := 0 - cursorY := 0 - wrappedCursor := self.origCursorToWrappedCursor(self.cursor) - for _, r := range self.wrappedContent[0:wrappedCursor] { - if r == '\n' { - cursorY++ - cursorX = 0 - } else { - chWidth := runewidth.RuneWidth(r) - cursorX += chWidth - } + if len(self.cells) == 0 { + return 0, 0 } - - return cursorX, cursorY + cellCursor := self.contentCursorToCellCursor(self.cursor) + if cellCursor >= len(self.cells) { + return self.cells[len(self.cells)-1].nextCursorXY() + } + if cellCursor > 0 && self.cells[cellCursor].char == "\n" { + return self.cells[cellCursor-1].nextCursorXY() + } + cell := self.cells[cellCursor] + return cell.x, cell.y } // takes an x,y position and maps it to a 1D cursor position @@ -470,25 +532,24 @@ func (self *TextArea) SetCursor2D(x int, y int) { } newCursor := 0 - for _, r := range self.wrappedContent { + for _, c := range self.cells { if x <= 0 && y == 0 { - self.cursor = self.wrappedCursorToOrigCursor(newCursor) - if self.wrappedContent[newCursor] == '\n' { + self.cursor = self.cellCursorToContentCursor(newCursor) + if self.cells[newCursor].char == "\n" { self.moveLeftFromSoftLineBreak() } return } - if r == '\n' { + if c.char == "\n" { if y == 0 { - self.cursor = self.wrappedCursorToOrigCursor(newCursor) + self.cursor = self.cellCursorToContentCursor(newCursor) self.moveLeftFromSoftLineBreak() return } y-- } else if y == 0 { - chWidth := runewidth.RuneWidth(r) - x -= chWidth + x -= c.width } newCursor++ @@ -500,17 +561,22 @@ func (self *TextArea) SetCursor2D(x int, y int) { return } - self.cursor = self.wrappedCursorToOrigCursor(newCursor) + self.cursor = self.cellCursorToContentCursor(newCursor) } func (self *TextArea) Clear() { - self.content = []rune{} - self.wrappedContent = []rune{} + self.content = "" + self.cells = nil self.cursor = 0 } func (self *TextArea) TypeString(str string) { - for _, r := range str { - self.TypeRune(r) + state := -1 + for str != "" { + var chr string + chr, str, _, state = uniseg.FirstGraphemeClusterInString(str, state) + self.typeCharacter(chr) } + + self.updateCells() } diff --git a/text_area_test.go b/text_area_test.go index 289dffef..0ef82bea 100644 --- a/text_area_test.go +++ b/text_area_test.go @@ -2,6 +2,7 @@ package gocui import ( "reflect" + "strconv" "testing" "github.com/stretchr/testify/assert" @@ -10,15 +11,16 @@ import ( func TestTextArea(t *testing.T) { tests := []struct { actions func(*TextArea) + wrapWidth int expectedContent string expectedCursor int expectedClipboard string }{ { actions: func(textarea *TextArea) { - textarea.TypeRune('a') - textarea.TypeRune('b') - textarea.TypeRune('c') + textarea.TypeCharacter("a") + textarea.TypeCharacter("b") + textarea.TypeCharacter("c") }, expectedContent: "abc", expectedCursor: 3, @@ -26,9 +28,9 @@ func TestTextArea(t *testing.T) { }, { actions: func(textarea *TextArea) { - textarea.TypeRune('a') - textarea.TypeRune('\n') - textarea.TypeRune('c') + textarea.TypeCharacter("a") + textarea.TypeCharacter("\n") + textarea.TypeCharacter("c") }, expectedContent: "a\nc", expectedCursor: 3, @@ -47,7 +49,7 @@ func TestTextArea(t *testing.T) { textarea.TypeString("a字cd") }, expectedContent: "a字cd", - expectedCursor: 4, + expectedCursor: 6, expectedClipboard: "", }, { @@ -60,7 +62,7 @@ func TestTextArea(t *testing.T) { }, { actions: func(textarea *TextArea) { - textarea.TypeRune('a') + textarea.TypeCharacter("a") textarea.BackSpaceChar() }, expectedContent: "", @@ -69,8 +71,8 @@ func TestTextArea(t *testing.T) { }, { actions: func(textarea *TextArea) { - textarea.TypeRune('a') - textarea.TypeRune('b') + textarea.TypeCharacter("a") + textarea.TypeCharacter("b") textarea.BackSpaceChar() }, expectedContent: "a", @@ -87,7 +89,7 @@ func TestTextArea(t *testing.T) { }, { actions: func(textarea *TextArea) { - textarea.TypeRune('a') + textarea.TypeCharacter("a") textarea.DeleteChar() }, expectedContent: "a", @@ -96,7 +98,7 @@ func TestTextArea(t *testing.T) { }, { actions: func(textarea *TextArea) { - textarea.TypeRune('a') + textarea.TypeCharacter("a") textarea.MoveCursorLeft() textarea.DeleteChar() }, @@ -106,9 +108,9 @@ func TestTextArea(t *testing.T) { }, { actions: func(textarea *TextArea) { - textarea.TypeRune('a') - textarea.TypeRune('b') - textarea.TypeRune('c') + textarea.TypeCharacter("a") + textarea.TypeCharacter("b") + textarea.TypeCharacter("c") textarea.MoveCursorLeft() textarea.MoveCursorLeft() textarea.DeleteChar() @@ -127,7 +129,7 @@ func TestTextArea(t *testing.T) { }, { actions: func(textarea *TextArea) { - textarea.TypeRune('a') + textarea.TypeCharacter("a") textarea.MoveCursorLeft() }, expectedContent: "a", @@ -136,8 +138,8 @@ func TestTextArea(t *testing.T) { }, { actions: func(textarea *TextArea) { - textarea.TypeRune('a') - textarea.TypeRune('b') + textarea.TypeCharacter("a") + textarea.TypeCharacter("b") textarea.MoveCursorLeft() }, expectedContent: "ab", @@ -154,7 +156,7 @@ func TestTextArea(t *testing.T) { }, { actions: func(textarea *TextArea) { - textarea.TypeRune('a') + textarea.TypeCharacter("a") textarea.MoveCursorRight() }, expectedContent: "a", @@ -163,8 +165,8 @@ func TestTextArea(t *testing.T) { }, { actions: func(textarea *TextArea) { - textarea.TypeRune('a') - textarea.TypeRune('b') + textarea.TypeCharacter("a") + textarea.TypeCharacter("b") textarea.MoveCursorLeft() textarea.MoveCursorRight() }, @@ -174,19 +176,19 @@ func TestTextArea(t *testing.T) { }, { actions: func(textarea *TextArea) { - textarea.TypeRune('漢') - textarea.TypeRune('字') + textarea.TypeCharacter("漢") + textarea.TypeCharacter("字") textarea.MoveCursorLeft() }, expectedContent: "漢字", - expectedCursor: 1, + expectedCursor: 3, expectedClipboard: "", }, { actions: func(textarea *TextArea) { textarea.ToggleOverwrite() - textarea.TypeRune('a') - textarea.TypeRune('b') + textarea.TypeCharacter("a") + textarea.TypeCharacter("b") }, expectedContent: "ab", expectedCursor: 2, @@ -194,13 +196,13 @@ func TestTextArea(t *testing.T) { }, { actions: func(textarea *TextArea) { - textarea.TypeRune('a') - textarea.TypeRune('b') - textarea.TypeRune('c') + textarea.TypeCharacter("a") + textarea.TypeCharacter("b") + textarea.TypeCharacter("c") textarea.MoveCursorLeft() textarea.MoveCursorLeft() textarea.ToggleOverwrite() - textarea.TypeRune('d') + textarea.TypeCharacter("d") }, expectedContent: "adc", expectedCursor: 2, @@ -214,6 +216,24 @@ func TestTextArea(t *testing.T) { expectedContent: "aaa bbb", expectedCursor: 4, }, + { + actions: func(textarea *TextArea) { + textarea.TypeString("aaa\nbbb") + textarea.MoveLeftWord() + }, + expectedContent: "aaa\nbbb", + expectedCursor: 4, + }, + { + actions: func(textarea *TextArea) { + textarea.TypeString("aaa bbb") + textarea.GoToStartOfLine() + textarea.MoveLeftWord() + }, + wrapWidth: 4, + expectedContent: "aaa bbb", + expectedCursor: 0, + }, { actions: func(textarea *TextArea) { textarea.TypeString("aaa bbb\n") @@ -258,6 +278,17 @@ func TestTextArea(t *testing.T) { expectedContent: "aaa\nbbb", expectedCursor: 4, }, + { + actions: func(textarea *TextArea) { + textarea.TypeString("aaa bbb") + textarea.GoToStartOfLine() + textarea.MoveCursorLeft() + textarea.MoveRightWord() + }, + wrapWidth: 4, + expectedContent: "aaa bbb", + expectedCursor: 7, + }, { actions: func(textarea *TextArea) { textarea.TypeString("aaa bbb") @@ -281,11 +312,11 @@ func TestTextArea(t *testing.T) { { actions: func(textarea *TextArea) { // overwrite mode acts same as normal mode when cursor is at the end - textarea.TypeRune('a') - textarea.TypeRune('b') - textarea.TypeRune('c') + textarea.TypeCharacter("a") + textarea.TypeCharacter("b") + textarea.TypeCharacter("c") textarea.ToggleOverwrite() - textarea.TypeRune('d') + textarea.TypeCharacter("d") }, expectedContent: "abcd", expectedCursor: 4, @@ -301,8 +332,8 @@ func TestTextArea(t *testing.T) { }, { actions: func(textarea *TextArea) { - textarea.TypeRune('a') - textarea.TypeRune('b') + textarea.TypeCharacter("a") + textarea.TypeCharacter("b") textarea.DeleteToStartOfLine() }, expectedContent: "", @@ -311,8 +342,8 @@ func TestTextArea(t *testing.T) { }, { actions: func(textarea *TextArea) { - textarea.TypeRune('a') - textarea.TypeRune('b') + textarea.TypeCharacter("a") + textarea.TypeCharacter("b") textarea.MoveCursorLeft() textarea.MoveCursorLeft() textarea.DeleteToStartOfLine() @@ -323,9 +354,9 @@ func TestTextArea(t *testing.T) { }, { actions: func(textarea *TextArea) { - textarea.TypeRune('a') - textarea.TypeRune('b') - textarea.TypeRune('\n') + textarea.TypeCharacter("a") + textarea.TypeCharacter("b") + textarea.TypeCharacter("\n") textarea.DeleteToStartOfLine() }, expectedContent: "ab", @@ -334,11 +365,11 @@ func TestTextArea(t *testing.T) { }, { actions: func(textarea *TextArea) { - textarea.TypeRune('a') - textarea.TypeRune('b') - textarea.TypeRune('\n') - textarea.TypeRune('c') - textarea.TypeRune('d') + textarea.TypeCharacter("a") + textarea.TypeCharacter("b") + textarea.TypeCharacter("\n") + textarea.TypeCharacter("c") + textarea.TypeCharacter("d") textarea.DeleteToStartOfLine() }, expectedContent: "ab\n", @@ -355,7 +386,7 @@ func TestTextArea(t *testing.T) { }, { actions: func(textarea *TextArea) { - textarea.TypeRune('a') + textarea.TypeCharacter("a") textarea.MoveCursorLeft() textarea.GoToStartOfLine() }, @@ -365,11 +396,11 @@ func TestTextArea(t *testing.T) { }, { actions: func(textarea *TextArea) { - textarea.TypeRune('a') - textarea.TypeRune('b') - textarea.TypeRune('\n') - textarea.TypeRune('c') - textarea.TypeRune('d') + textarea.TypeCharacter("a") + textarea.TypeCharacter("b") + textarea.TypeCharacter("\n") + textarea.TypeCharacter("c") + textarea.TypeCharacter("d") textarea.MoveCursorLeft() textarea.MoveCursorLeft() textarea.MoveCursorLeft() @@ -381,11 +412,11 @@ func TestTextArea(t *testing.T) { }, { actions: func(textarea *TextArea) { - textarea.TypeRune('a') - textarea.TypeRune('b') - textarea.TypeRune('\n') - textarea.TypeRune('c') - textarea.TypeRune('d') + textarea.TypeCharacter("a") + textarea.TypeCharacter("b") + textarea.TypeCharacter("\n") + textarea.TypeCharacter("c") + textarea.TypeCharacter("d") textarea.GoToStartOfLine() }, expectedContent: "ab\ncd", @@ -394,11 +425,11 @@ func TestTextArea(t *testing.T) { }, { actions: func(textarea *TextArea) { - textarea.TypeRune('a') - textarea.TypeRune('b') - textarea.TypeRune('\n') - textarea.TypeRune('c') - textarea.TypeRune('d') + textarea.TypeCharacter("a") + textarea.TypeCharacter("b") + textarea.TypeCharacter("\n") + textarea.TypeCharacter("c") + textarea.TypeCharacter("d") textarea.MoveCursorLeft() textarea.MoveCursorLeft() textarea.GoToStartOfLine() @@ -417,11 +448,11 @@ func TestTextArea(t *testing.T) { }, { actions: func(textarea *TextArea) { - textarea.TypeRune('a') - textarea.TypeRune('b') - textarea.TypeRune('\n') - textarea.TypeRune('c') - textarea.TypeRune('d') + textarea.TypeCharacter("a") + textarea.TypeCharacter("b") + textarea.TypeCharacter("\n") + textarea.TypeCharacter("c") + textarea.TypeCharacter("d") textarea.MoveCursorLeft() textarea.MoveCursorLeft() textarea.GoToEndOfLine() @@ -502,7 +533,7 @@ func TestTextArea(t *testing.T) { textarea.MoveCursorDown() }, expectedContent: "abcdef\n老老老", - expectedCursor: 9, + expectedCursor: 13, expectedClipboard: "", }, { @@ -679,18 +710,25 @@ func TestTextArea(t *testing.T) { }, } - for _, test := range tests { - textarea := &TextArea{} - test.actions(textarea) - assert.EqualValues(t, test.expectedContent, textarea.GetContent()) - assert.EqualValues(t, test.expectedCursor, textarea.cursor) - assert.EqualValues(t, test.expectedClipboard, textarea.clipboard) + for i, test := range tests { + t.Run(strconv.Itoa(i+1), func(t *testing.T) { + textarea := &TextArea{} + if test.wrapWidth > 0 { + textarea.AutoWrap = true + textarea.AutoWrapWidth = test.wrapWidth + } + test.actions(textarea) + assert.EqualValues(t, test.expectedContent, textarea.GetUnwrappedContent()) + assert.EqualValues(t, test.expectedCursor, textarea.cursor) + assert.EqualValues(t, test.expectedClipboard, textarea.clipboard) + }) } } func TestGetCursorXY(t *testing.T) { tests := []struct { actions func(*TextArea) + wrapWidth int expectedX int expectedY int }{ @@ -701,6 +739,36 @@ func TestGetCursorXY(t *testing.T) { expectedX: 0, expectedY: 0, }, + { + actions: func(textarea *TextArea) { + textarea.TypeString("\n") + }, + expectedX: 0, + expectedY: 1, + }, + { + actions: func(textarea *TextArea) { + textarea.TypeString("\na") + }, + expectedX: 1, + expectedY: 1, + }, + { + actions: func(textarea *TextArea) { + textarea.TypeString("\n") + textarea.MoveCursorUp() + }, + expectedX: 0, + expectedY: 0, + }, + { + actions: func(textarea *TextArea) { + textarea.TypeString("\n\n") + textarea.MoveCursorUp() + }, + expectedX: 0, + expectedY: 1, + }, { actions: func(textarea *TextArea) { textarea.TypeString("ab\ncd") @@ -708,6 +776,21 @@ func TestGetCursorXY(t *testing.T) { expectedX: 2, expectedY: 1, }, + { + actions: func(textarea *TextArea) { + textarea.TypeString("ab\n") + }, + expectedX: 0, + expectedY: 1, + }, + { + actions: func(textarea *TextArea) { + textarea.TypeString("ab\n") + textarea.MoveCursorLeft() + }, + expectedX: 2, + expectedY: 0, + }, { actions: func(textarea *TextArea) { textarea.TypeString("ab\n\n") @@ -717,20 +800,69 @@ func TestGetCursorXY(t *testing.T) { }, { actions: func(textarea *TextArea) { - textarea.TypeRune('漢') - textarea.TypeRune('字') + textarea.TypeString("ab\n\n") + textarea.MoveCursorLeft() + }, + expectedX: 0, + expectedY: 1, + }, + { + actions: func(textarea *TextArea) { + textarea.TypeCharacter("漢") + textarea.TypeCharacter("字") }, expectedX: 4, expectedY: 0, }, + { + actions: func(textarea *TextArea) { + textarea.TypeString("abc de") + textarea.MoveCursorLeft() + }, + wrapWidth: 4, + expectedX: 1, + expectedY: 1, + }, + { + actions: func(textarea *TextArea) { + textarea.TypeString("abc de") + textarea.MoveCursorLeft() + textarea.MoveCursorLeft() + }, + wrapWidth: 4, + expectedX: 0, + expectedY: 1, + }, + { + actions: func(textarea *TextArea) { + textarea.TypeString("abc de") + textarea.MoveCursorLeft() + textarea.MoveCursorLeft() + textarea.MoveCursorLeft() + }, + wrapWidth: 4, + expectedX: 3, + expectedY: 0, + }, } - for _, test := range tests { - textarea := &TextArea{} - test.actions(textarea) - x, y := textarea.GetCursorXY() - assert.EqualValues(t, test.expectedX, x) - assert.EqualValues(t, test.expectedY, y) + for i, test := range tests { + t.Run(strconv.Itoa(i+1), func(t *testing.T) { + textarea := &TextArea{} + if test.wrapWidth > 0 { + textarea.AutoWrap = true + textarea.AutoWrapWidth = test.wrapWidth + } + test.actions(textarea) + x, y := textarea.GetCursorXY() + assert.EqualValues(t, test.expectedX, x) + assert.EqualValues(t, test.expectedY, y) + + // As a sanity check, test that setting the cursor back to (x, y) results in the same cursor position: + cursor := textarea.cursor + textarea.SetCursor2D(x, y) + assert.EqualValues(t, cursor, textarea.cursor) + }) } } @@ -740,104 +872,123 @@ func Test_AutoWrapContent(t *testing.T) { content string autoWrapWidth int expectedWrappedContent string - expectedCursorMapping []CursorMapping + expectedSoftLineBreaks []int }{ { name: "empty content", content: "", autoWrapWidth: 7, expectedWrappedContent: "", - expectedCursorMapping: []CursorMapping{}, + expectedSoftLineBreaks: []int{}, }, { name: "no wrapping necessary", content: "abcde", autoWrapWidth: 7, expectedWrappedContent: "abcde", - expectedCursorMapping: []CursorMapping{}, + expectedSoftLineBreaks: []int{}, }, { name: "wrap at whitespace", content: "abcde xyz", autoWrapWidth: 7, expectedWrappedContent: "abcde \nxyz", - expectedCursorMapping: []CursorMapping{{6, 7}}, + expectedSoftLineBreaks: []int{6}, + }, + { + name: "take wide characters into account", + content: "🏴󠁧󠁢󠁥󠁮󠁧󠁿 🏴󠁧󠁢󠁥󠁮󠁧󠁿 x y", // the flag has a width of 2 + autoWrapWidth: 7, + expectedWrappedContent: "🏴󠁧󠁢󠁥󠁮󠁧󠁿 🏴󠁧󠁢󠁥󠁮󠁧󠁿 x \ny", + expectedSoftLineBreaks: []int{60}, + }, + { + name: "take wide characters into account at line end", + content: "🏴󠁧󠁢󠁥󠁮󠁧󠁿 🏴󠁧󠁢󠁥󠁮󠁧󠁿 🏴󠁧󠁢󠁥󠁮󠁧󠁿", // the flag has a width of 2 + autoWrapWidth: 7, + expectedWrappedContent: "🏴󠁧󠁢󠁥󠁮󠁧󠁿 🏴󠁧󠁢󠁥󠁮󠁧󠁿 \n🏴󠁧󠁢󠁥󠁮󠁧󠁿", + expectedSoftLineBreaks: []int{58}, }, { name: "lots of whitespace is preserved at end of line", content: "abcde xyz", autoWrapWidth: 7, expectedWrappedContent: "abcde \nxyz", - expectedCursorMapping: []CursorMapping{{11, 12}}, + expectedSoftLineBreaks: []int{11}, }, { name: "don't wrap inside long word when there's no whitespace", content: "abc defghijklmn opq", autoWrapWidth: 7, expectedWrappedContent: "abc \ndefghijklmn \nopq", - expectedCursorMapping: []CursorMapping{{4, 5}, {16, 18}}, + expectedSoftLineBreaks: []int{4, 16}, }, { name: "don't break at space after footnote symbol", content: "abc\n[1]: https://long/link\ndef", autoWrapWidth: 7, expectedWrappedContent: "abc\n[1]: https://long/link\ndef", - expectedCursorMapping: []CursorMapping{}, + expectedSoftLineBreaks: []int{}, }, { name: "don't break at space after footnote symbol at soft line start", content: "abc def [1]: https://long/link\nghi", autoWrapWidth: 7, expectedWrappedContent: "abc def \n[1]: https://long/link\nghi", - expectedCursorMapping: []CursorMapping{{8, 9}}, + expectedSoftLineBreaks: []int{8}, }, { name: "do break at subsequent space after footnote symbol", content: "abc\n[1]: normal text follows\ndef", autoWrapWidth: 7, expectedWrappedContent: "abc\n[1]: normal \ntext \nfollows\ndef", - expectedCursorMapping: []CursorMapping{{16, 17}, {21, 23}}, + expectedSoftLineBreaks: []int{16, 21}, }, { name: "hard line breaks", content: "abc\ndef\n", autoWrapWidth: 7, expectedWrappedContent: "abc\ndef\n", - expectedCursorMapping: []CursorMapping{}, + expectedSoftLineBreaks: []int{}, }, { name: "mixture of hard and soft line breaks", content: "abc def ghi jkl mno\npqr stu vwx yz\n", autoWrapWidth: 7, expectedWrappedContent: "abc def \nghi jkl \nmno\npqr stu \nvwx yz\n", - expectedCursorMapping: []CursorMapping{{8, 9}, {16, 18}, {28, 31}}, + expectedSoftLineBreaks: []int{8, 16, 28}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - wrappedContent, cursorMapping := AutoWrapContent([]rune(tt.content), tt.autoWrapWidth) - if !reflect.DeepEqual(wrappedContent, []rune(tt.expectedWrappedContent)) { - t.Errorf("autoWrapContentImpl() wrappedContent = %v, expected %v", string(wrappedContent), tt.expectedWrappedContent) + textArea := &TextArea{content: tt.content, AutoWrapWidth: tt.autoWrapWidth, AutoWrap: true} + cells, softLineBreakIndices := contentToCells(tt.content, tt.autoWrapWidth) + textArea.cells = cells + if !reflect.DeepEqual(textArea.GetContent(), tt.expectedWrappedContent) { + t.Errorf("autoWrapContentImpl() wrappedContent = %v, expected %v", textArea.GetContent(), tt.expectedWrappedContent) } - if !reflect.DeepEqual(cursorMapping, tt.expectedCursorMapping) { - t.Errorf("autoWrapContentImpl() cursorMapping = %v, expected %v", cursorMapping, tt.expectedCursorMapping) + if !reflect.DeepEqual(softLineBreakIndices, tt.expectedSoftLineBreaks) { + t.Errorf("autoWrapContentImpl() softLineBreakIndices = %v, expected %v", softLineBreakIndices, tt.expectedSoftLineBreaks) } - // As a sanity check, run through all runes of the original content, - // convert the cursor to the wrapped cursor, and check that the rune + // As a sanity check, run through all characters of the original content, + // convert the cursor to the wrapped cursor, and check that the character // in the wrapped content at that position is the same: - for i, r := range tt.content { - wrappedIndex := origCursorToWrappedCursor(i, cursorMapping) - if r != wrappedContent[wrappedIndex] { - t.Errorf("Runes in orig content and wrapped content don't match at %d: expected %v, got %v", i, r, wrappedContent[wrappedIndex]) + origCursor := 0 + for _, chr := range stringToGraphemes(tt.content) { + wrappedIndex := textArea.contentCursorToCellCursor(origCursor) + if chr != textArea.cells[wrappedIndex].char { + t.Errorf("Runes in orig content and wrapped content don't match at %d: expected %v, got %v", origCursor, chr, textArea.cells[wrappedIndex].char) } // Also, check that converting the wrapped position back to the // orig position yields the original value again: - origIndexAgain := wrappedCursorToOrigCursor(wrappedIndex, cursorMapping) - if i != origIndexAgain { - t.Errorf("wrappedCursorToOrigCursor doesn't yield original position: expected %d, got %d", i, origIndexAgain) + origIndexAgain := textArea.cellCursorToContentCursor(wrappedIndex) + if origCursor != origIndexAgain { + t.Errorf("wrappedCursorToOrigCursor doesn't yield original position: expected %d, got %d", origCursor, origIndexAgain) } + + origCursor += len(chr) } }) } diff --git a/view.go b/view.go index 0d0b19ef..e5b5c046 100644 --- a/view.go +++ b/view.go @@ -5,7 +5,6 @@ package gocui import ( - "bytes" "fmt" "io" "strings" @@ -14,7 +13,7 @@ import ( "unicode/utf8" "github.com/gdamore/tcell/v2" - "github.com/mattn/go-runewidth" + "github.com/rivo/uniseg" ) // Constants for overlapping edges @@ -160,7 +159,7 @@ type View struct { // If Mask is true, the View will display the mask instead of the real // content - Mask rune + Mask string // Overlaps describes which edges are overlapping with another view's edges Overlaps byte @@ -397,20 +396,25 @@ type viewLine struct { } type cell struct { - chr rune + chr string // a grapheme cluster + width int // number of terminal cells occupied by chr (always 1 or 2) bgColor, fgColor Attribute hyperlink string } type lineType []cell +func characterEquals(chr []byte, b byte) bool { + return len(chr) == 1 && chr[0] == b +} + // String returns a string from a given cell slice. func (l lineType) String() string { - str := "" + var str strings.Builder for _, c := range l { - str += string(c.chr) + str.WriteString(c.chr) } - return str + return str.String() } // NewView returns a new View object. @@ -492,16 +496,16 @@ func (v *View) Name() string { return v.name } -// setRune sets a rune at the given point relative to the view. It applies the -// specified colors, taking into account if the cell must be highlighted. Also, -// it checks if the position is valid. -func (v *View) setRune(x, y int, ch rune, fgColor, bgColor Attribute) { +// setCharacter sets a character (grapheme cluster) at the given point relative to the view. It applies +// the specified colors, taking into account if the cell must be highlighted. Also, it checks if the +// position is valid. +func (v *View) setCharacter(x, y int, ch string, fgColor, bgColor Attribute) { maxX, maxY := v.Size() if x < 0 || x >= maxX || y < 0 || y >= maxY { return } - if v.Mask != 0 { + if v.Mask != "" { fgColor = v.FgColor bgColor = v.BgColor ch = v.Mask @@ -542,28 +546,14 @@ func (v *View) setRune(x, y int, ch rune, fgColor, bgColor Attribute) { fgColor |= AttrUnderline } - // Don't display NUL characters - if ch == 0 { - ch = ' ' + // Don't display empty characters + if ch == "" { + ch = " " } tcellSetCell(v.x0+x+1, v.y0+y+1, ch, fgColor, bgColor, v.outMode) } -func min(a, b int) int { - if a < b { - return a - } - return b -} - -func max(a, b int) int { - if a > b { - return a - } - return b -} - // SetCursor sets the cursor position of the view at the given point, // relative to the view. It is allowed to set the position to a point outside // the visible portion of the view, or even outside the content of the view. @@ -711,25 +701,26 @@ func (v *View) makeWriteable(x, y int) { } } -// writeCells copies []cell to specified location (x, y) +// writeCells copies []cell to (v.wx, v.wy), and advances v.wx accordingly. // !!! caller MUST ensure that specified location (x, y) is writeable by calling makeWriteable -func (v *View) writeCells(x, y int, cells []cell) { +func (v *View) writeCells(cells []cell) { var newLen int // use maximum len available - line := v.lines[y][:cap(v.lines[y])] - maxCopy := len(line) - x + line := v.lines[v.wy][:cap(v.lines[v.wy])] + maxCopy := len(line) - v.wx if maxCopy < len(cells) { - copy(line[x:], cells[:maxCopy]) + copy(line[v.wx:], cells[:maxCopy]) line = append(line, cells[maxCopy:]...) newLen = len(line) } else { // maxCopy >= len(cells) - copy(line[x:], cells) - newLen = x + len(cells) - if newLen < len(v.lines[y]) { - newLen = len(v.lines[y]) + copy(line[v.wx:], cells) + newLen = v.wx + len(cells) + if newLen < len(v.lines[v.wy]) { + newLen = len(v.lines[v.wy]) } } - v.lines[y] = line[:newLen] + v.lines[v.wy] = line[:newLen] + v.wx += len(cells) } // Write appends a byte slice into the view's internal buffer. Because @@ -740,20 +731,12 @@ func (v *View) Write(p []byte) (n int, err error) { v.writeMutex.Lock() defer v.writeMutex.Unlock() - v.writeRunes(bytes.Runes(p)) + v.write(p) return len(p), nil } -func (v *View) WriteRunes(p []rune) { - v.writeMutex.Lock() - defer v.writeMutex.Unlock() - - v.writeRunes(p) -} - -// writeRunes copies slice of runes into internal lines buffer. -func (v *View) writeRunes(p []rune) { +func (v *View) write(p []byte) { v.tainted = true v.clearHover() @@ -763,8 +746,9 @@ func (v *View) writeRunes(p []rune) { finishLine := func() { v.autoRenderHyperlinksInCurrentLine() if v.wx >= len(v.lines[v.wy]) { - v.writeCells(v.wx, v.wy, []cell{{ - chr: 0, + v.writeCells([]cell{{ + chr: "", + width: 0, fgColor: 0, bgColor: 0, }}) @@ -790,25 +774,29 @@ func (v *View) writeRunes(p []rune) { until-- } - for _, r := range p[:until] { - switch r { - case '\n': + state := -1 + var chr []byte + var width int + remaining := p[:until] + + for len(remaining) > 0 { + chr, remaining, width, state = uniseg.FirstGraphemeCluster(remaining, state) + + switch { + case characterEquals(chr, '\n'): finishLine() advanceToNextLine() - case '\r': + case characterEquals(chr, '\r'): finishLine() v.wx = 0 default: - truncateLine, cells := v.parseInput(r, v.wx, v.wy) + truncateLine, cells := v.parseInput(chr, width, v.wx, v.wy) if cells == nil { continue } - v.writeCells(v.wx, v.wy, cells) + v.writeCells(cells) if truncateLine { - length := v.wx + len(cells) - v.lines[v.wy] = v.lines[v.wy][:length] - } else { - v.wx += len(cells) + v.lines[v.wy] = v.lines[v.wy][:v.wx] } } } @@ -825,20 +813,22 @@ func (v *View) writeRunes(p []rune) { // exported functions use the mutex. Non-exported functions are for internal use // and a calling function should use a mutex func (v *View) WriteString(s string) { - v.WriteRunes([]rune(s)) + _, _ = v.Write([]byte(s)) } func (v *View) writeString(s string) { - v.writeRunes([]rune(s)) + v.write([]byte(s)) } -func findSubstring(line []cell, substringToFind []rune) int { - for i := 0; i < len(line)-len(substringToFind); i++ { - for j := 0; j < len(substringToFind); j++ { - if line[i+j].chr != substringToFind[j] { +var linkStartChars = []string{"h", "t", "t", "p", "s", ":", "/", "/"} + +func findLinkStart(line []cell) int { + for i := 0; i < len(line)-len(linkStartChars); i++ { + for j := range linkStartChars { + if line[i+j].chr != string(linkStartChars[j]) { break } - if j == len(substringToFind)-1 { + if j == len(linkStartChars)-1 { return i } } @@ -846,42 +836,43 @@ func findSubstring(line []cell, substringToFind []rune) int { return -1 } +// We need a heuristic to find the end of a hyperlink. Searching for the +// first character that is not a valid URI character is not quite good +// enough, because in markdown it's common to have a hyperlink followed by a +// ')', so we want to stop there. Hopefully URLs containing ')' are uncommon +// enough that this is not a problem. +var lineEndCharacters map[string]bool = map[string]bool{ + "": true, + " ": true, + "\n": true, + ">": true, + "\"": true, + ")": true, +} + func (v *View) autoRenderHyperlinksInCurrentLine() { if !v.AutoRenderHyperLinks { return } - // We need a heuristic to find the end of a hyperlink. Searching for the - // first character that is not a valid URI character is not quite good - // enough, because in markdown it's common to have a hyperlink followed by a - // ')', so we want to stop there. Hopefully URLs containing ')' are uncommon - // enough that this is not a problem. - lineEndCharacters := map[rune]bool{ - '\000': true, - ' ': true, - '\n': true, - '>': true, - '"': true, - ')': true, - } line := v.lines[v.wy] start := 0 for { - linkStart := findSubstring(line[start:], []rune("https://")) + linkStart := findLinkStart(line[start:]) if linkStart == -1 { break } linkStart += start - link := "" + var link strings.Builder linkEnd := linkStart for ; linkEnd < len(line); linkEnd++ { if _, ok := lineEndCharacters[line[linkEnd].chr]; ok { break } - link += string(line[linkEnd].chr) + link.WriteString(string(line[linkEnd].chr)) } for i := linkStart; i < linkEnd; i++ { - v.lines[v.wy][i].hyperlink = link + v.lines[v.wy][i].hyperlink = link.String() } start = linkEnd } @@ -890,17 +881,18 @@ func (v *View) autoRenderHyperlinksInCurrentLine() { // parseInput parses char by char the input written to the View. It returns nil // while processing ESC sequences. Otherwise, it returns a cell slice that // contains the processed data. -func (v *View) parseInput(ch rune, x int, _ int) (bool, []cell) { +func (v *View) parseInput(ch []byte, width int, x int, _ int) (bool, []cell) { cells := []cell{} truncateLine := false isEscape, err := v.ei.parseOne(ch) if err != nil { - for _, r := range v.ei.runes() { + for _, chr := range v.ei.characters() { c := cell{ fgColor: v.FgColor, bgColor: v.BgColor, - chr: r, + chr: chr, + width: uniseg.StringWidth(chr), } cells = append(cells, c) } @@ -912,28 +904,31 @@ func (v *View) parseInput(ch rune, x int, _ int) (bool, []cell) { v.ei.instructionRead() cx := 0 for _, cell := range v.lines[v.wy][0:v.wx] { - cx += runewidth.RuneWidth(cell.chr) + cx += cell.width } repeatCount = v.InnerWidth() - cx - ch = ' ' + ch = []byte{' '} + width = 1 truncateLine = true } else if isEscape { // do not output anything return truncateLine, nil - } else if ch == '\t' { + } else if characterEquals(ch, '\t') { // fill tab-sized space tabWidth := v.TabWidth if tabWidth < 1 { tabWidth = 4 } - ch = ' ' + ch = []byte{' '} + width = 1 repeatCount = tabWidth - (x % tabWidth) } c := cell{ fgColor: v.ei.curFgColor, bgColor: v.ei.curBgColor, - hyperlink: v.ei.hyperlink, - chr: ch, + hyperlink: v.ei.hyperlink.String(), + chr: string(ch), + width: width, } for i := 0; i < repeatCount; i++ { cells = append(cells, c) @@ -961,8 +956,9 @@ func (v *View) Read(p []byte) (n int, err error) { } for v.ry < len(v.lines) { for v.rx < len(v.lines[v.ry]) { - count := utf8.EncodeRune(buffer, v.lines[v.ry][v.rx].chr) - copy(p[offset:], buffer[:count]) + s := v.lines[v.ry][v.rx].chr + count := len(s) + copy(p[offset:], s) v.rx++ newOffset := offset + count if newOffset >= len(p) { @@ -1063,43 +1059,54 @@ func containsUpcaseChar(str string) bool { return false } +func stringToGraphemes(s string) []string { + var graphemes []string + state := -1 + for s != "" { + var chr string + chr, s, _, state = uniseg.FirstGraphemeClusterInString(s, state) + graphemes = append(graphemes, chr) + } + return graphemes +} + func (v *View) updateSearchPositions() { if v.searcher.searchString != "" { - var normalizeRune func(r rune) rune + var normalizeRune func(s string) string var normalizedSearchStr string // if we have any uppercase characters we'll do a case-sensitive search if containsUpcaseChar(v.searcher.searchString) { - normalizeRune = func(r rune) rune { return r } + normalizeRune = func(s string) string { return s } normalizedSearchStr = v.searcher.searchString } else { - normalizeRune = unicode.ToLower + normalizeRune = strings.ToLower normalizedSearchStr = strings.ToLower(v.searcher.searchString) } + searchStrGraphemes := stringToGraphemes(normalizedSearchStr) + v.searcher.searchPositions = []SearchPosition{} searchPositionsForLine := func(line []cell, y int) []SearchPosition { var result []SearchPosition - searchStringWidth := runewidth.StringWidth(v.searcher.searchString) + searchStringWidth := uniseg.StringWidth(v.searcher.searchString) x := 0 - for startIdx, c := range line { + for startIdx, cell := range line { found := true - offset := 0 - for _, c := range normalizedSearchStr { - if len(line)-1 < startIdx+offset { + for i, c := range searchStrGraphemes { + if len(line)-1 < startIdx+i { found = false break } - if normalizeRune(line[startIdx+offset].chr) != c { + if normalizeRune(line[startIdx+i].chr) != c { found = false break } - offset += 1 } if found { result = append(result, SearchPosition{XStart: x, XEnd: x + searchStringWidth, Y: y}) } - x += runewidth.RuneWidth(c.chr) + x += cell.width } return result } @@ -1185,7 +1192,7 @@ func (v *View) draw() { start = len(v.viewLines) - 1 } - emptyCell := cell{chr: ' ', fgColor: ColorDefault, bgColor: ColorDefault} + emptyCell := cell{chr: " ", width: 1, fgColor: ColorDefault, bgColor: ColorDefault} var prevFgColor Attribute for y, vline := range v.viewLines[start:] { @@ -1207,7 +1214,7 @@ func (v *View) draw() { if x < 0 { if cellIdx < len(vline.line) { - x += runewidth.RuneWidth(vline.line[cellIdx].chr) + x += uniseg.StringWidth(vline.line[cellIdx].chr) cellIdx++ continue } else { @@ -1241,11 +1248,9 @@ func (v *View) draw() { fgColor |= AttrUnderline } - v.setRune(x, y, c.chr, fgColor, bgColor) + v.setCharacter(x, y, c.chr, fgColor, bgColor) - // Not sure why the previous code was here but it caused problems - // when typing wide characters in an editor - x += runewidth.RuneWidth(c.chr) + x += c.width cellIdx++ } } @@ -1345,9 +1350,9 @@ func (v *View) realPosition(vx, vy int) (x, y int, ok bool) { // clearRunes erases all the cells in the view. func (v *View) clearRunes() { maxX, maxY := v.InnerSize() - for x := 0; x < maxX; x++ { - for y := 0; y < maxY; y++ { - tcellSetCell(v.x0+x+1, v.y0+y+1, ' ', v.FgColor, v.BgColor, v.outMode) + for x := range maxX { + for y := range maxY { + tcellSetCell(v.x0+x+1, v.y0+y+1, " ", v.FgColor, v.BgColor, v.outMode) } } } @@ -1500,7 +1505,7 @@ func lineWrap(line []cell, columns int) [][]cell { lines := make([][]cell, 0, 1) for i := range line { currChr := line[i].chr - rw := runewidth.RuneWidth(currChr) + rw := uniseg.StringWidth(currChr) n += rw // if currChr == 'g' { // panic(n) @@ -1508,21 +1513,21 @@ func lineWrap(line []cell, columns int) [][]cell { if n > columns { // This code is convoluted but we've got comprehensive tests so feel free to do whatever you want // to the code to simplify it so long as our tests still pass. - if currChr == ' ' { + if currChr == " " { // if the line ends in a space, we'll omit it. This means there'll be no // way to distinguish between a clean break and a mid-word break, but // I think it's worth it. lines = append(lines, line[offset:i]) offset = i + 1 n = 0 - } else if currChr == '-' { + } else if currChr == "-" { // if the last character is hyphen and the width of line is equal to the columns lines = append(lines, line[offset:i]) offset = i n = rw } else if lastWhitespaceIndex != -1 { // if there is a space in the line and the line is not breaking at a space/hyphen - if line[lastWhitespaceIndex].chr == '-' { + if line[lastWhitespaceIndex].chr == "-" { // if break occurs at hyphen, we'll retain the hyphen lines = append(lines, line[offset:lastWhitespaceIndex+1]) } else { @@ -1533,7 +1538,7 @@ func lineWrap(line []cell, columns int) [][]cell { offset = lastWhitespaceIndex + 1 n = 0 for _, c := range line[offset : i+1] { - n += runewidth.RuneWidth(c.chr) + n += c.width } } else { // in this case we're breaking mid-word @@ -1542,7 +1547,7 @@ func lineWrap(line []cell, columns int) [][]cell { n = rw } lastWhitespaceIndex = -1 - } else if line[i].chr == ' ' || line[i].chr == '-' { + } else if line[i].chr == " " || line[i].chr == "-" { lastWhitespaceIndex = i } } @@ -1581,11 +1586,11 @@ func (v *View) GetClickedTabIndex(x int) int { return -1 } for i, tab := range v.Tabs { - charX += runewidth.StringWidth(tab) + charX += uniseg.StringWidth(tab) if x <= charX { return i } - charX += runewidth.StringWidth(" - ") + charX += uniseg.StringWidth(" - ") if x <= charX { return -1 } @@ -1848,7 +1853,7 @@ func containsColoredTextInLine(fgColorStr string, text string, line []cell) bool fgColor := tcell.GetColor(fgColorStr) currentMatch := "" - for i := 0; i < len(line); i++ { + for i := range line { cell := line[i] // stripping attributes by converting to and from hex diff --git a/view_test.go b/view_test.go index 7d136b01..a7023be4 100644 --- a/view_test.go +++ b/view_test.go @@ -5,76 +5,78 @@ package gocui import ( + "strings" "testing" + "github.com/rivo/uniseg" "github.com/stretchr/testify/assert" ) -func TestWriteRunes(t *testing.T) { +func TestWriteString(t *testing.T) { tests := []struct { existingLines []string stringsToWrite []string - expectedLines []string + expectedLines [][]string }{ { []string{}, []string{""}, - []string{""}, + [][]string{{}}, }, { []string{}, []string{"1\n"}, - []string{"1\x00"}, + [][]string{{"1", ""}}, }, { []string{}, []string{"1\n", "2\n"}, - []string{"1\x00", "2\x00"}, + [][]string{{"1", ""}, {"2", ""}}, }, { []string{"a"}, []string{"1\n"}, - []string{"1\x00"}, + [][]string{{"1", ""}}, }, { []string{"a\x00"}, []string{"1\n"}, - []string{"1\x00"}, + [][]string{{"1", "\x00"}}, }, { []string{"ab"}, []string{"1\n"}, - []string{"1b"}, + [][]string{{"1", "b"}}, }, { []string{"abc"}, []string{"1\n"}, - []string{"1bc"}, + [][]string{{"1", "b", "c"}}, }, { []string{}, []string{"1\r"}, - []string{"1\x00"}, + [][]string{{"1", ""}}, }, { []string{"a"}, []string{"1\r"}, - []string{"1\x00"}, + [][]string{{"1", ""}}, }, { []string{"a\x00"}, []string{"1\r"}, - []string{"1\x00"}, + [][]string{{"1", "\x00"}}, }, { []string{"ab"}, []string{"1\r"}, - []string{"1b"}, + [][]string{{"1", "b"}}, }, { []string{"abc"}, []string{"1\r"}, - []string{"1bc"}, + [][]string{{"1", "b", "c"}}, }, } @@ -84,11 +86,11 @@ func TestWriteRunes(t *testing.T) { v.lines = append(v.lines, stringToCells(l)) } for _, s := range test.stringsToWrite { - v.writeRunes([]rune(s)) + v.writeString(s) } - var resultingLines []string + var resultingLines [][]string for _, l := range v.lines { - resultingLines = append(resultingLines, cellsToString(l)) + resultingLines = append(resultingLines, cellsToStrings(l)) } assert.Equal(t, test.expectedLines, resultingLines) } @@ -122,20 +124,20 @@ func TestAutoRenderingHyperlinks(t *testing.T) { v := NewView("name", 0, 0, 10, 10, OutputNormal) v.AutoRenderHyperLinks = true - v.writeRunes([]rune("htt")) + v.writeString("htt") // No hyperlinks are generated for incomplete URLs assert.Equal(t, "", v.lines[0][0].hyperlink) // Writing more characters to the same line makes the link complete (even // though we didn't see a newline yet) - v.writeRunes([]rune("ps://example.com")) + v.writeString("ps://example.com") assert.Equal(t, "https://example.com", v.lines[0][0].hyperlink) v.Clear() // Valid but incomplete URL - v.writeRunes([]rune("https://exa")) + v.writeString("https://exa") assert.Equal(t, "https://exa", v.lines[0][0].hyperlink) // Writing more characters to the same fixes the link - v.writeRunes([]rune("mple.com")) + v.writeString("mple.com") assert.Equal(t, "https://example.com", v.lines[0][0].hyperlink) } @@ -144,7 +146,7 @@ func TestContainsColoredText(t *testing.T) { cells := make([]cell, len(text)) hex := GetColor(hexStr) for i, chr := range text { - cells[i] = cell{fgColor: hex, chr: chr} + cells[i] = cell{fgColor: hex, chr: string(chr)} } return cells } @@ -216,16 +218,28 @@ func TestContainsColoredText(t *testing.T) { func stringToCells(s string) []cell { var cells []cell - for _, c := range s { - cells = append(cells, cell{chr: c}) + state := -1 + for len(s) > 0 { + var c string + var w int + c, s, w, state = uniseg.FirstGraphemeClusterInString(s, state) + cells = append(cells, cell{chr: c, width: w}) } return cells } func cellsToString(cells []cell) string { - var s string + var s strings.Builder + for _, c := range cells { + s.WriteString(c.chr) + } + return s.String() +} + +func cellsToStrings(cells []cell) []string { + s := []string{} for _, c := range cells { - s += string(c.chr) + s = append(s, c.chr) } return s }