From a61b82b71b2f78b54b3bc5f7b3fdef6536253b66 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 4 Dec 2025 08:20:45 +0100 Subject: [PATCH 01/18] Change TextArea tests to test the methods instead of free-standing functions Originally I had extracted free-standing functions because they are easier to test, but it's actually not hard at all to instantiate a TextArea and test its methods, and it gives us more flexibility to refactor the internals. --- text_area_test.go | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/text_area_test.go b/text_area_test.go index 289dffef..ca59acdd 100644 --- a/text_area_test.go +++ b/text_area_test.go @@ -815,26 +815,27 @@ func Test_AutoWrapContent(t *testing.T) { } 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: []rune(tt.content), AutoWrapWidth: tt.autoWrapWidth, AutoWrap: true} + textArea.autoWrapContent() + if !reflect.DeepEqual(textArea.wrappedContent, []rune(tt.expectedWrappedContent)) { + t.Errorf("autoWrapContentImpl() wrappedContent = %v, expected %v", string(textArea.wrappedContent), tt.expectedWrappedContent) } - if !reflect.DeepEqual(cursorMapping, tt.expectedCursorMapping) { - t.Errorf("autoWrapContentImpl() cursorMapping = %v, expected %v", cursorMapping, tt.expectedCursorMapping) + if !reflect.DeepEqual(textArea.cursorMapping, tt.expectedCursorMapping) { + t.Errorf("autoWrapContentImpl() cursorMapping = %v, expected %v", textArea.cursorMapping, tt.expectedCursorMapping) } // As a sanity check, run through all runes of the original content, // convert the cursor to the wrapped cursor, and check that the rune // 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]) + wrappedIndex := textArea.origCursorToWrappedCursor(i) + if r != textArea.wrappedContent[wrappedIndex] { + t.Errorf("Runes in orig content and wrapped content don't match at %d: expected %v, got %v", i, r, textArea.wrappedContent[wrappedIndex]) } // Also, check that converting the wrapped position back to the // orig position yields the original value again: - origIndexAgain := wrappedCursorToOrigCursor(wrappedIndex, cursorMapping) + origIndexAgain := textArea.wrappedCursorToOrigCursor(wrappedIndex) if i != origIndexAgain { t.Errorf("wrappedCursorToOrigCursor doesn't yield original position: expected %d, got %d", i, origIndexAgain) } From 58dd9c27b79412f2d743300f571a0cd6897c4e02 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 4 Dec 2025 08:22:21 +0100 Subject: [PATCH 02/18] Inline the free-standing functions into the methods Now that we no longer test them, there's no longer any reason for the indirection. --- text_area.go | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/text_area.go b/text_area.go index 1194b272..a215de33 100644 --- a/text_area.go +++ b/text_area.go @@ -411,9 +411,9 @@ func (self *TextArea) Yank() { self.TypeString(self.clipboard) } -func origCursorToWrappedCursor(origCursor int, cursorMapping []CursorMapping) int { +func (self *TextArea) origCursorToWrappedCursor(origCursor int) int { prevMapping := CursorMapping{0, 0} - for _, mapping := range cursorMapping { + for _, mapping := range self.cursorMapping { if origCursor < mapping.Orig { break } @@ -423,13 +423,9 @@ func origCursorToWrappedCursor(origCursor int, cursorMapping []CursorMapping) in return origCursor + prevMapping.Wrapped - prevMapping.Orig } -func (self *TextArea) origCursorToWrappedCursor(origCursor int) int { - return origCursorToWrappedCursor(origCursor, self.cursorMapping) -} - -func wrappedCursorToOrigCursor(wrappedCursor int, cursorMapping []CursorMapping) int { +func (self *TextArea) wrappedCursorToOrigCursor(wrappedCursor int) int { prevMapping := CursorMapping{0, 0} - for _, mapping := range cursorMapping { + for _, mapping := range self.cursorMapping { if wrappedCursor < mapping.Wrapped { break } @@ -439,10 +435,6 @@ func wrappedCursorToOrigCursor(wrappedCursor int, cursorMapping []CursorMapping) return wrappedCursor + prevMapping.Orig - prevMapping.Wrapped } -func (self *TextArea) wrappedCursorToOrigCursor(wrappedCursor int) int { - return wrappedCursorToOrigCursor(wrappedCursor, self.cursorMapping) -} - func (self *TextArea) GetCursorXY() (int, int) { cursorX := 0 cursorY := 0 From cbc54c95f6c1af03ddac00c24d8f47c5607ee514 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 1 Dec 2025 21:13:03 +0100 Subject: [PATCH 03/18] Update go version to 1.25 --- .github/workflows/ci.yml | 4 ++-- go.mod | 15 +++++++++++++-- 2 files changed, 15 insertions(+), 4 deletions(-) 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/go.mod b/go.mod index 7277243c..518f7edc 100644 --- a/go.mod +++ b/go.mod @@ -1,11 +1,22 @@ module github.com/jesseduffield/gocui -go 1.12 +go 1.25 require ( github.com/gdamore/tcell/v2 v2.8.0 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/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.2.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + golang.org/x/sys v0.29.0 // indirect + golang.org/x/term v0.28.0 // indirect + golang.org/x/text v0.21.0 // indirect + gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c // indirect +) From 173e16d3712df422b7abf097e921b0dddeec6cc9 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 14 Dec 2025 15:16:32 +0100 Subject: [PATCH 04/18] Run gopls modernize go run golang.org/x/tools/gopls/internal/analysis/modernize/cmd/modernize@latest -fix -test ./... --- gui.go | 32 +++++++++++++------------------- gui_others.go | 1 - gui_windows.go | 1 - keybinding.go | 16 ++++++++-------- view.go | 34 ++++++++++------------------------ view_test.go | 7 ++++--- 6 files changed, 35 insertions(+), 56 deletions(-) diff --git a/gui.go b/gui.go index dac90169..f6c7357b 100644 --- a/gui.go +++ b/gui.go @@ -8,6 +8,7 @@ import ( "context" standardErrors "errors" "runtime" + "slices" "strings" "sync" "time" @@ -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 @@ -554,7 +555,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 +573,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 +624,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 +652,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 @@ -1485,7 +1484,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 +1501,7 @@ func IsMouseKey(key interface{}) bool { } } -func IsMouseScrollKey(key interface{}) bool { +func IsMouseScrollKey(key any) bool { switch key { case MouseWheelUp, @@ -1640,12 +1639,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,7 +1693,7 @@ 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) if charWidth == 0 { 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/view.go b/view.go index 0d0b19ef..6d85ee8b 100644 --- a/view.go +++ b/view.go @@ -406,11 +406,11 @@ type lineType []cell // 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(string(c.chr)) } - return str + return str.String() } // NewView returns a new View object. @@ -550,20 +550,6 @@ func (v *View) setRune(x, y int, ch rune, fgColor, bgColor Attribute) { 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. @@ -834,7 +820,7 @@ func (v *View) writeString(s string) { func findSubstring(line []cell, substringToFind []rune) int { for i := 0; i < len(line)-len(substringToFind); i++ { - for j := 0; j < len(substringToFind); j++ { + for j := range substringToFind { if line[i+j].chr != substringToFind[j] { break } @@ -872,16 +858,16 @@ func (v *View) autoRenderHyperlinksInCurrentLine() { 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 } @@ -1345,8 +1331,8 @@ 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++ { + for x := range maxX { + for y := range maxY { tcellSetCell(v.x0+x+1, v.y0+y+1, ' ', v.FgColor, v.BgColor, v.outMode) } } @@ -1848,7 +1834,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..c10ddced 100644 --- a/view_test.go +++ b/view_test.go @@ -5,6 +5,7 @@ package gocui import ( + "strings" "testing" "github.com/stretchr/testify/assert" @@ -223,11 +224,11 @@ func stringToCells(s string) []cell { } func cellsToString(cells []cell) string { - var s string + var s strings.Builder for _, c := range cells { - s += string(c.chr) + s.WriteString(string(c.chr)) } - return s + return s.String() } func TestLineWrap(t *testing.T) { From 48ef1cafdddadd7c624b768be661be452116b3b4 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 2 Dec 2025 09:21:09 +0100 Subject: [PATCH 05/18] Avoid auto-wrapping the text area after each inserted character for TypeString Once at the end is enough. --- text_area.go | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/text_area.go b/text_area.go index a215de33..ad2c8db7 100644 --- a/text_area.go +++ b/text_area.go @@ -115,7 +115,7 @@ func (self *TextArea) autoWrapContent() { } } -func (self *TextArea) TypeRune(r rune) { +func (self *TextArea) typeRune(r rune) { if self.overwrite && !self.atEnd() { self.content[self.cursor] = r } else { @@ -124,11 +124,15 @@ func (self *TextArea) TypeRune(r rune) { append([]rune{r}, self.content[self.cursor:]...)..., ) } - self.autoWrapContent() self.cursor++ } +func (self *TextArea) TypeRune(r rune) { + self.typeRune(r) + self.autoWrapContent() +} + func (self *TextArea) BackSpaceChar() { if self.cursor == 0 { return @@ -503,6 +507,8 @@ func (self *TextArea) Clear() { func (self *TextArea) TypeString(str string) { for _, r := range str { - self.TypeRune(r) + self.typeRune(r) } + + self.autoWrapContent() } From d5a6563e51f13025f3a1cd59dd86b8ab94c70bf3 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sat, 6 Dec 2025 14:20:14 +0100 Subject: [PATCH 06/18] Add indices as test names Without any test names it is impossible to find out which test failed. Coming up with names is too much work; using indices is a good middle ground. Still not very convenient, but at least it is possible to find out which test case it was by counting. --- text_area_test.go | 29 +++++++++++++++++------------ 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/text_area_test.go b/text_area_test.go index ca59acdd..5815586e 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" @@ -679,12 +680,14 @@ 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{} + test.actions(textarea) + assert.EqualValues(t, test.expectedContent, textarea.GetContent()) + assert.EqualValues(t, test.expectedCursor, textarea.cursor) + assert.EqualValues(t, test.expectedClipboard, textarea.clipboard) + }) } } @@ -725,12 +728,14 @@ func TestGetCursorXY(t *testing.T) { }, } - 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{} + test.actions(textarea) + x, y := textarea.GetCursorXY() + assert.EqualValues(t, test.expectedX, x) + assert.EqualValues(t, test.expectedY, y) + }) } } From 1904921f9f68b65d5f4994cdd4c97fe2ae58d61f Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 5 Dec 2025 20:18:10 +0100 Subject: [PATCH 07/18] Add more tests --- text_area_test.go | 124 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 123 insertions(+), 1 deletion(-) diff --git a/text_area_test.go b/text_area_test.go index 5815586e..ee02eadc 100644 --- a/text_area_test.go +++ b/text_area_test.go @@ -11,6 +11,7 @@ import ( func TestTextArea(t *testing.T) { tests := []struct { actions func(*TextArea) + wrapWidth int expectedContent string expectedCursor int expectedClipboard string @@ -215,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") @@ -259,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") @@ -683,8 +713,12 @@ func TestTextArea(t *testing.T) { 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.GetContent()) + assert.EqualValues(t, test.expectedContent, textarea.GetUnwrappedContent()) assert.EqualValues(t, test.expectedCursor, textarea.cursor) assert.EqualValues(t, test.expectedClipboard, textarea.clipboard) }) @@ -694,6 +728,7 @@ func TestTextArea(t *testing.T) { func TestGetCursorXY(t *testing.T) { tests := []struct { actions func(*TextArea) + wrapWidth int expectedX int expectedY int }{ @@ -704,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") @@ -711,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") @@ -718,6 +798,14 @@ func TestGetCursorXY(t *testing.T) { expectedX: 0, expectedY: 2, }, + { + actions: func(textarea *TextArea) { + textarea.TypeString("ab\n\n") + textarea.MoveCursorLeft() + }, + expectedX: 0, + expectedY: 1, + }, { actions: func(textarea *TextArea) { textarea.TypeRune('漢') @@ -726,11 +814,45 @@ func TestGetCursorXY(t *testing.T) { 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 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) From c1e910d142c6b8aac6b2bbbd26a78d2e7ba07334 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 7 Dec 2025 11:07:44 +0100 Subject: [PATCH 08/18] Add sanity check to GetCursorXY test --- text_area_test.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/text_area_test.go b/text_area_test.go index ee02eadc..bddee821 100644 --- a/text_area_test.go +++ b/text_area_test.go @@ -857,6 +857,11 @@ func TestGetCursorXY(t *testing.T) { 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) }) } } From 7dae125dd0d79f9f9eb0b8e928cab1abd3c6b584 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 7 Dec 2025 18:05:47 +0100 Subject: [PATCH 09/18] Refactor: extract common code of MoveLeftWord and BackSpaceWord They both have exactly the same logic, the only difference is that BackSpaceWord also deletes the characters between the old and new cursor position. --- text_area.go | 54 ++++++++++++++++++++++------------------------------ 1 file changed, 23 insertions(+), 31 deletions(-) diff --git a/text_area.go b/text_area.go index ad2c8db7..b08692a7 100644 --- a/text_area.go +++ b/text_area.go @@ -168,28 +168,34 @@ func (self *TextArea) MoveCursorRight() { self.cursor++ } -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-- + cursor := self.cursor + for cursor > 0 && strings.ContainsRune(WHITESPACES, self.content[cursor-1]) { + cursor-- } separators := false - for !self.atLineStart() && strings.ContainsRune(WORD_SEPARATORS, self.content[self.cursor-1]) { - self.cursor-- + for cursor > 0 && strings.ContainsRune(WORD_SEPARATORS, self.content[cursor-1]) { + cursor-- separators = true } if !separators { - for !self.atLineStart() && !strings.ContainsRune(WHITESPACES+WORD_SEPARATORS, self.content[self.cursor-1]) { - self.cursor-- + for cursor > 0 && self.content[cursor-1] != '\n' && !strings.ContainsRune(WHITESPACES+WORD_SEPARATORS, self.content[cursor-1]) { + cursor-- } } + + return cursor +} + +func (self *TextArea) MoveLeftWord() { + self.cursor = self.newCursorForMoveLeftWord() } func (self *TextArea) MoveRightWord() { @@ -383,31 +389,17 @@ func (self *TextArea) atSoftLineEnd() bool { } 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-- + clipboard := string(self.content[newCursor:self.cursor]) + if clipboard != "\n" { + self.clipboard = clipboard } - separators := false - for !self.atLineStart() && strings.ContainsRune(WORD_SEPARATORS, self.content[self.cursor-1]) { - self.cursor-- - separators = true - } - 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.content = append(self.content[:newCursor], self.content[self.cursor:]...) + self.cursor = newCursor self.autoWrapContent() } From 5b1a2bbf28169989c76377f11124624432234b4e Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 14 Dec 2025 11:58:49 +0100 Subject: [PATCH 10/18] Refactor: get rid of unnecessary offset variable --- view.go | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/view.go b/view.go index 6d85ee8b..74f0dbfb 100644 --- a/view.go +++ b/view.go @@ -1070,17 +1070,15 @@ func (v *View) updateSearchPositions() { x := 0 for startIdx, c := range line { found := true - offset := 0 - for _, c := range normalizedSearchStr { - if len(line)-1 < startIdx+offset { + for i, c := range normalizedSearchStr { + 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}) From f5e8329967faf23b3d68b5bc8c6073912ccb7b45 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 3 Dec 2025 21:02:45 +0100 Subject: [PATCH 11/18] Bump tcell dependency We bump it to the latest version of v2 for now; upgrading to v3 requires even more changes and will happen another time. --- go.mod | 13 +++++++------ go.sum | 57 ++++++++++++++------------------------------------------- 2 files changed, 21 insertions(+), 49 deletions(-) diff --git a/go.mod b/go.mod index 518f7edc..9bbe52f5 100644 --- a/go.mod +++ b/go.mod @@ -3,20 +3,21 @@ module github.com/jesseduffield/gocui 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/mattn/go-runewidth v0.0.19 github.com/stretchr/testify v1.7.0 ) require ( + github.com/clipperhouse/uax29/v2 v2.2.0 // indirect github.com/davecgh/go-spew v1.1.0 // indirect github.com/gdamore/encoding v1.0.1 // indirect - github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect - golang.org/x/sys v0.29.0 // indirect - golang.org/x/term v0.28.0 // indirect - golang.org/x/text v0.21.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..f527a0ca 100644 --- a/go.sum +++ b/go.sum @@ -1,20 +1,19 @@ +github.com/clipperhouse/uax29/v2 v2.2.0 h1:ChwIKnQN3kcZteTXMgb1wztSgaU+ZemkgWdohwgs8tY= +github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= 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/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= 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 +22,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= From 7b2338392236bacd84e3bfff701115ef7afc088d Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sat, 22 Nov 2025 15:15:44 +0100 Subject: [PATCH 12/18] Use string instead of rune for cell content, and use proper unicode segmentation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This includes a complete reimplementation of the TextArea class. This is necessary for proper handling of multi-rune grapheme clusters such as 🏴󠁧󠁢󠁥󠁮󠁧󠁿 or ⚡️. --- edit.go | 6 +- escape.go | 75 +++++----- escape_test.go | 6 +- go.mod | 2 +- gui.go | 16 +- loader.go | 10 +- tcell_driver.go | 6 +- text_area.go | 371 ++++++++++++++++++++++++++++------------------ text_area_test.go | 210 ++++++++++++++------------ view.go | 173 +++++++++++---------- view_test.go | 60 +++++--- 11 files changed, 531 insertions(+), 404 deletions(-) diff --git a/edit.go b/edit.go index 4ced2ad0..b23f0a45 100644 --- a/edit.go +++ b/edit.go @@ -46,9 +46,9 @@ func SimpleEditor(v *View, key Key, ch rune, mod Modifier) bool { 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: @@ -64,7 +64,7 @@ func SimpleEditor(v *View, key Key, ch rune, mod Modifier) bool { case key == KeyCtrlY: v.TextArea.Yank() case unicode.IsPrint(ch): - v.TextArea.TypeRune(ch) + 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 9bbe52f5..d973c51d 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/gdamore/tcell/v2 v2.13.5 github.com/go-errors/errors v1.0.2 github.com/mattn/go-runewidth v0.0.19 + github.com/rivo/uniseg v0.4.7 github.com/stretchr/testify v1.7.0 ) @@ -15,7 +16,6 @@ require ( 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 - github.com/rivo/uniseg v0.4.7 // 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 diff --git a/gui.go b/gui.go index f6c7357b..7388ef02 100644 --- a/gui.go +++ b/gui.go @@ -16,6 +16,7 @@ import ( "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 @@ -301,12 +302,13 @@ 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 } @@ -1368,13 +1370,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 += uniseg.StringWidth(c.chr) } - if lastCharForLine < newX { - newX = lastCharForLine - newCx = lastCharForLine - v.ox + if visibleLineWidth < newX { + newX = visibleLineWidth + newCx = visibleLineWidth - v.ox } } if !IsMouseScrollKey(ev.Key) { 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..1fc34884 100644 --- a/tcell_driver.go +++ b/tcell_driver.go @@ -97,10 +97,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 b08692a7..e7aa6db7 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,30 +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.cursor++ + self.content = self.content[:self.cursor] + ch + self.content[self.cursor+widthToDelete:] + self.cursor += len(ch) } -func (self *TextArea) TypeRune(r rune) { - self.typeRune(r) - self.autoWrapContent() +func (self *TextArea) TypeCharacter(ch string) { + self.typeCharacter(ch) + self.updateCells() } func (self *TextArea) BackSpaceChar() { @@ -138,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() { @@ -148,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() { @@ -157,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() { @@ -165,7 +232,8 @@ func (self *TextArea) MoveCursorRight() { return } - self.cursor++ + s, _, _, _ := uniseg.FirstGraphemeClusterInString(self.content[self.cursor:], -1) + self.cursor += len(s) } func (self *TextArea) newCursorForMoveLeftWord() int { @@ -176,22 +244,22 @@ func (self *TextArea) newCursorForMoveLeftWord() int { return self.cursor - 1 } - cursor := self.cursor - for cursor > 0 && strings.ContainsRune(WHITESPACES, self.content[cursor-1]) { - 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 cursor > 0 && strings.ContainsRune(WORD_SEPARATORS, self.content[cursor-1]) { - cursor-- + for cellCursor > 0 && strings.Contains(WORD_SEPARATORS, self.cells[cellCursor-1].char) { + cellCursor-- separators = true } if !separators { - for cursor > 0 && self.content[cursor-1] != '\n' && !strings.ContainsRune(WHITESPACES+WORD_SEPARATORS, self.content[cursor-1]) { - cursor-- + for cellCursor > 0 && self.cells[cellCursor-1].char != "\n" && !strings.Contains(WHITESPACES+WORD_SEPARATORS, self.cells[cellCursor-1].char) { + cellCursor-- } } - return cursor + return self.cellCursorToContentCursor(cellCursor) } func (self *TextArea) MoveLeftWord() { @@ -207,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() { @@ -233,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() { @@ -256,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 } @@ -273,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 } @@ -286,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 } @@ -300,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() { @@ -310,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() { @@ -345,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) } } @@ -371,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 { @@ -383,9 +465,9 @@ 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() { @@ -394,58 +476,50 @@ func (self *TextArea) BackSpaceWord() { return } - clipboard := string(self.content[newCursor:self.cursor]) + clipboard := self.content[newCursor:self.cursor] if clipboard != "\n" { self.clipboard = clipboard } - self.content = append(self.content[:newCursor], self.content[self.cursor:]...) + self.content = self.content[:newCursor] + self.content[self.cursor:] self.cursor = newCursor - self.autoWrapContent() + self.updateCells() } func (self *TextArea) Yank() { self.TypeString(self.clipboard) } -func (self *TextArea) origCursorToWrappedCursor(origCursor int) int { - prevMapping := CursorMapping{0, 0} - for _, mapping := range self.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 + return idx } -func (self *TextArea) wrappedCursorToOrigCursor(wrappedCursor int) int { - prevMapping := CursorMapping{0, 0} - for _, mapping := range self.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 + 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 @@ -458,24 +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) + chWidth := uniseg.StringWidth(c.char) x -= chWidth } @@ -488,19 +562,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.autoWrapContent() + self.updateCells() } diff --git a/text_area_test.go b/text_area_test.go index bddee821..0ef82bea 100644 --- a/text_area_test.go +++ b/text_area_test.go @@ -18,9 +18,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") }, expectedContent: "abc", expectedCursor: 3, @@ -28,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, @@ -49,7 +49,7 @@ func TestTextArea(t *testing.T) { textarea.TypeString("a字cd") }, expectedContent: "a字cd", - expectedCursor: 4, + expectedCursor: 6, expectedClipboard: "", }, { @@ -62,7 +62,7 @@ func TestTextArea(t *testing.T) { }, { actions: func(textarea *TextArea) { - textarea.TypeRune('a') + textarea.TypeCharacter("a") textarea.BackSpaceChar() }, expectedContent: "", @@ -71,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", @@ -89,7 +89,7 @@ func TestTextArea(t *testing.T) { }, { actions: func(textarea *TextArea) { - textarea.TypeRune('a') + textarea.TypeCharacter("a") textarea.DeleteChar() }, expectedContent: "a", @@ -98,7 +98,7 @@ func TestTextArea(t *testing.T) { }, { actions: func(textarea *TextArea) { - textarea.TypeRune('a') + textarea.TypeCharacter("a") textarea.MoveCursorLeft() textarea.DeleteChar() }, @@ -108,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() @@ -129,7 +129,7 @@ func TestTextArea(t *testing.T) { }, { actions: func(textarea *TextArea) { - textarea.TypeRune('a') + textarea.TypeCharacter("a") textarea.MoveCursorLeft() }, expectedContent: "a", @@ -138,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", @@ -156,7 +156,7 @@ func TestTextArea(t *testing.T) { }, { actions: func(textarea *TextArea) { - textarea.TypeRune('a') + textarea.TypeCharacter("a") textarea.MoveCursorRight() }, expectedContent: "a", @@ -165,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() }, @@ -176,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, @@ -196,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, @@ -312,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, @@ -332,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: "", @@ -342,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() @@ -354,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", @@ -365,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", @@ -386,7 +386,7 @@ func TestTextArea(t *testing.T) { }, { actions: func(textarea *TextArea) { - textarea.TypeRune('a') + textarea.TypeCharacter("a") textarea.MoveCursorLeft() textarea.GoToStartOfLine() }, @@ -396,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() @@ -412,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", @@ -425,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() @@ -448,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() @@ -533,7 +533,7 @@ func TestTextArea(t *testing.T) { textarea.MoveCursorDown() }, expectedContent: "abcdef\n老老老", - expectedCursor: 9, + expectedCursor: 13, expectedClipboard: "", }, { @@ -808,8 +808,8 @@ func TestGetCursorXY(t *testing.T) { }, { actions: func(textarea *TextArea) { - textarea.TypeRune('漢') - textarea.TypeRune('字') + textarea.TypeCharacter("漢") + textarea.TypeCharacter("字") }, expectedX: 4, expectedY: 0, @@ -872,105 +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) { - textArea := &TextArea{content: []rune(tt.content), AutoWrapWidth: tt.autoWrapWidth, AutoWrap: true} - textArea.autoWrapContent() - if !reflect.DeepEqual(textArea.wrappedContent, []rune(tt.expectedWrappedContent)) { - t.Errorf("autoWrapContentImpl() wrappedContent = %v, expected %v", string(textArea.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(textArea.cursorMapping, tt.expectedCursorMapping) { - t.Errorf("autoWrapContentImpl() cursorMapping = %v, expected %v", textArea.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 := textArea.origCursorToWrappedCursor(i) - if r != textArea.wrappedContent[wrappedIndex] { - t.Errorf("Runes in orig content and wrapped content don't match at %d: expected %v, got %v", i, r, textArea.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 := textArea.wrappedCursorToOrigCursor(wrappedIndex) - 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 74f0dbfb..07b672bc 100644 --- a/view.go +++ b/view.go @@ -5,7 +5,6 @@ package gocui import ( - "bytes" "fmt" "io" "strings" @@ -15,6 +14,7 @@ import ( "github.com/gdamore/tcell/v2" "github.com/mattn/go-runewidth" + "github.com/rivo/uniseg" ) // Constants for overlapping edges @@ -160,7 +160,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,18 +397,22 @@ type viewLine struct { } type cell struct { - chr rune + chr string // a grapheme cluster 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 { var str strings.Builder for _, c := range l { - str.WriteString(string(c.chr)) + str.WriteString(c.chr) } return str.String() } @@ -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,9 +546,9 @@ 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) @@ -726,20 +730,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() @@ -750,7 +746,7 @@ func (v *View) writeRunes(p []rune) { v.autoRenderHyperlinksInCurrentLine() if v.wx >= len(v.lines[v.wy]) { v.writeCells(v.wx, v.wy, []cell{{ - chr: 0, + chr: "", fgColor: 0, bgColor: 0, }}) @@ -776,16 +772,22 @@ func (v *View) writeRunes(p []rune) { until-- } - for _, r := range p[:until] { - switch r { - case '\n': + state := -1 + var chr []byte + remaining := p[:until] + + for len(remaining) > 0 { + chr, remaining, _, 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, v.wx, v.wy) if cells == nil { continue } @@ -811,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 := range substringToFind { - 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 } } @@ -832,28 +836,29 @@ 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 } @@ -876,17 +881,17 @@ 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, 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, } cells = append(cells, c) } @@ -898,28 +903,28 @@ 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 += uniseg.StringWidth(cell.chr) } repeatCount = v.InnerWidth() - cx - ch = ' ' + ch = []byte{' '} 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{' '} 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), } for i := 0; i < repeatCount; i++ { cells = append(cells, c) @@ -947,8 +952,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) { @@ -1049,28 +1055,41 @@ 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) x := 0 - for startIdx, c := range line { + for startIdx, cell := range line { found := true - for i, c := range normalizedSearchStr { + for i, c := range searchStrGraphemes { if len(line)-1 < startIdx+i { found = false break @@ -1083,7 +1102,7 @@ func (v *View) updateSearchPositions() { if found { result = append(result, SearchPosition{XStart: x, XEnd: x + searchStringWidth, Y: y}) } - x += runewidth.RuneWidth(c.chr) + x += uniseg.StringWidth(cell.chr) } return result } @@ -1169,7 +1188,7 @@ func (v *View) draw() { start = len(v.viewLines) - 1 } - emptyCell := cell{chr: ' ', fgColor: ColorDefault, bgColor: ColorDefault} + emptyCell := cell{chr: " ", fgColor: ColorDefault, bgColor: ColorDefault} var prevFgColor Attribute for y, vline := range v.viewLines[start:] { @@ -1191,7 +1210,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 { @@ -1225,11 +1244,11 @@ 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 += uniseg.StringWidth(c.chr) cellIdx++ } } @@ -1331,7 +1350,7 @@ func (v *View) clearRunes() { maxX, maxY := v.InnerSize() for x := range maxX { for y := range maxY { - tcellSetCell(v.x0+x+1, v.y0+y+1, ' ', v.FgColor, v.BgColor, v.outMode) + tcellSetCell(v.x0+x+1, v.y0+y+1, " ", v.FgColor, v.BgColor, v.outMode) } } } @@ -1484,7 +1503,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) @@ -1492,21 +1511,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 { @@ -1517,7 +1536,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 += uniseg.StringWidth(c.chr) } } else { // in this case we're breaking mid-word @@ -1526,7 +1545,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 } } diff --git a/view_test.go b/view_test.go index c10ddced..8b5aca8b 100644 --- a/view_test.go +++ b/view_test.go @@ -8,74 +8,75 @@ 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"}}, }, } @@ -85,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) } @@ -123,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) } @@ -145,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 } @@ -217,7 +218,10 @@ func TestContainsColoredText(t *testing.T) { func stringToCells(s string) []cell { var cells []cell - for _, c := range s { + state := -1 + for len(s) > 0 { + var c string + c, s, _, state = uniseg.FirstGraphemeClusterInString(s, state) cells = append(cells, cell{chr: c}) } return cells @@ -226,11 +230,19 @@ func stringToCells(s string) []cell { func cellsToString(cells []cell) string { var s strings.Builder for _, c := range cells { - s.WriteString(string(c.chr)) + s.WriteString(c.chr) } return s.String() } +func cellsToStrings(cells []cell) []string { + s := []string{} + for _, c := range cells { + s = append(s, c.chr) + } + return s +} + func TestLineWrap(t *testing.T) { testCases := []struct { name string From b825112da7021ee2345d6d6f2daa4be9d5a77d7e Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 24 Nov 2025 20:24:41 +0100 Subject: [PATCH 13/18] Replace other uses of go-runewidth with uniseg --- go.mod | 2 -- go.sum | 4 ---- gui.go | 13 ++++++------- tcell_driver.go | 2 -- view.go | 7 +++---- 5 files changed, 9 insertions(+), 19 deletions(-) diff --git a/go.mod b/go.mod index d973c51d..0f0841d4 100644 --- a/go.mod +++ b/go.mod @@ -5,13 +5,11 @@ go 1.25 require ( github.com/gdamore/tcell/v2 v2.13.5 github.com/go-errors/errors v1.0.2 - github.com/mattn/go-runewidth v0.0.19 github.com/rivo/uniseg v0.4.7 github.com/stretchr/testify v1.7.0 ) require ( - github.com/clipperhouse/uax29/v2 v2.2.0 // indirect 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 diff --git a/go.sum b/go.sum index f527a0ca..fd70acb3 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,3 @@ -github.com/clipperhouse/uax29/v2 v2.2.0 h1:ChwIKnQN3kcZteTXMgb1wztSgaU+ZemkgWdohwgs8tY= -github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= 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= @@ -10,8 +8,6 @@ github.com/go-errors/errors v1.0.2 h1:xMxH9j2fNg/L4hLn/4y3M0IUsn0M6Wbu/Uh9QlOfBh github.com/go-errors/errors v1.0.2/go.mod h1:psDX2osz5VnTOnFWbDeWwS7yejl+uV3FEWEp4lssFEs= 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/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= -github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= 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.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= diff --git a/gui.go b/gui.go index 7388ef02..b6f55e9e 100644 --- a/gui.go +++ b/gui.go @@ -15,7 +15,6 @@ import ( "github.com/gdamore/tcell/v2" "github.com/go-errors/errors" - "github.com/mattn/go-runewidth" "github.com/rivo/uniseg" ) @@ -1104,7 +1103,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 { @@ -1129,7 +1128,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 } @@ -1140,7 +1139,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 } @@ -1152,7 +1151,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 } @@ -1169,7 +1168,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 } @@ -1181,7 +1180,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 } diff --git a/tcell_driver.go b/tcell_driver.go index 1fc34884..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 { diff --git a/view.go b/view.go index 07b672bc..f839ec2d 100644 --- a/view.go +++ b/view.go @@ -13,7 +13,6 @@ import ( "unicode/utf8" "github.com/gdamore/tcell/v2" - "github.com/mattn/go-runewidth" "github.com/rivo/uniseg" ) @@ -1085,7 +1084,7 @@ func (v *View) updateSearchPositions() { 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, cell := range line { found := true @@ -1584,11 +1583,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 } From ee5c4961d8d110d5a178a95f4f147ee72d2c278f Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sat, 13 Dec 2025 14:21:11 +0100 Subject: [PATCH 14/18] Adapt Snapshot function to new tcell API --- gui.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gui.go b/gui.go index b6f55e9e..bfe488bb 100644 --- a/gui.go +++ b/gui.go @@ -1696,11 +1696,11 @@ func (g *Gui) Snapshot() string { 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 } From 999ad2e99cf94224e2d33495ff46c6b3f8343dff Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sat, 13 Dec 2025 14:22:44 +0100 Subject: [PATCH 15/18] Remove unused function Gui.Rune It uses a deprecated API, and would have to be changed to return a string rather than a rune. However, since there are no callers we might as well delete it. --- gui.go | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/gui.go b/gui.go index bfe488bb..d5469e93 100644 --- a/gui.go +++ b/gui.go @@ -311,16 +311,6 @@ func (g *Gui) SetRune(x, y int, ch rune, fgColor, bgColor Attribute) error { 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 From feeafb7b21fc8dd56d5aa7d05133a2e160076de7 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 30 Nov 2025 17:29:38 +0100 Subject: [PATCH 16/18] Cache width in cell struct I haven't measured if this actually makes a difference, but it seems like a sensible thing to do. --- gui.go | 2 +- text_area.go | 3 +-- view.go | 55 +++++++++++++++++++++++++++------------------------- view_test.go | 5 +++-- 4 files changed, 34 insertions(+), 31 deletions(-) diff --git a/gui.go b/gui.go index d5469e93..39ba4743 100644 --- a/gui.go +++ b/gui.go @@ -1361,7 +1361,7 @@ func (g *Gui) onKey(ev *GocuiEvent) error { visibleLineWidth := 0 for _, c := range v.lines[newY] { - visibleLineWidth += uniseg.StringWidth(c.chr) + visibleLineWidth += c.width } if visibleLineWidth < newX { newX = visibleLineWidth diff --git a/text_area.go b/text_area.go index e7aa6db7..da5562f8 100644 --- a/text_area.go +++ b/text_area.go @@ -549,8 +549,7 @@ func (self *TextArea) SetCursor2D(x int, y int) { } y-- } else if y == 0 { - chWidth := uniseg.StringWidth(c.char) - x -= chWidth + x -= c.width } newCursor++ diff --git a/view.go b/view.go index f839ec2d..e5b5c046 100644 --- a/view.go +++ b/view.go @@ -397,6 +397,7 @@ type viewLine struct { type cell struct { chr string // a grapheme cluster + width int // number of terminal cells occupied by chr (always 1 or 2) bgColor, fgColor Attribute hyperlink string } @@ -700,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 @@ -744,8 +746,9 @@ func (v *View) write(p []byte) { finishLine := func() { v.autoRenderHyperlinksInCurrentLine() if v.wx >= len(v.lines[v.wy]) { - v.writeCells(v.wx, v.wy, []cell{{ + v.writeCells([]cell{{ chr: "", + width: 0, fgColor: 0, bgColor: 0, }}) @@ -773,10 +776,11 @@ func (v *View) write(p []byte) { state := -1 var chr []byte + var width int remaining := p[:until] for len(remaining) > 0 { - chr, remaining, _, state = uniseg.FirstGraphemeCluster(remaining, state) + chr, remaining, width, state = uniseg.FirstGraphemeCluster(remaining, state) switch { case characterEquals(chr, '\n'): @@ -786,16 +790,13 @@ func (v *View) write(p []byte) { finishLine() v.wx = 0 default: - truncateLine, cells := v.parseInput(chr, 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] } } } @@ -880,7 +881,7 @@ 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 []byte, x int, _ int) (bool, []cell) { +func (v *View) parseInput(ch []byte, width int, x int, _ int) (bool, []cell) { cells := []cell{} truncateLine := false @@ -891,6 +892,7 @@ func (v *View) parseInput(ch []byte, x int, _ int) (bool, []cell) { fgColor: v.FgColor, bgColor: v.BgColor, chr: chr, + width: uniseg.StringWidth(chr), } cells = append(cells, c) } @@ -902,10 +904,11 @@ func (v *View) parseInput(ch []byte, x int, _ int) (bool, []cell) { v.ei.instructionRead() cx := 0 for _, cell := range v.lines[v.wy][0:v.wx] { - cx += uniseg.StringWidth(cell.chr) + cx += cell.width } repeatCount = v.InnerWidth() - cx ch = []byte{' '} + width = 1 truncateLine = true } else if isEscape { // do not output anything @@ -917,6 +920,7 @@ func (v *View) parseInput(ch []byte, x int, _ int) (bool, []cell) { tabWidth = 4 } ch = []byte{' '} + width = 1 repeatCount = tabWidth - (x % tabWidth) } c := cell{ @@ -924,6 +928,7 @@ func (v *View) parseInput(ch []byte, x int, _ int) (bool, []cell) { bgColor: v.ei.curBgColor, hyperlink: v.ei.hyperlink.String(), chr: string(ch), + width: width, } for i := 0; i < repeatCount; i++ { cells = append(cells, c) @@ -1101,7 +1106,7 @@ func (v *View) updateSearchPositions() { if found { result = append(result, SearchPosition{XStart: x, XEnd: x + searchStringWidth, Y: y}) } - x += uniseg.StringWidth(cell.chr) + x += cell.width } return result } @@ -1187,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:] { @@ -1245,9 +1250,7 @@ func (v *View) draw() { 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 += uniseg.StringWidth(c.chr) + x += c.width cellIdx++ } } @@ -1535,7 +1538,7 @@ func lineWrap(line []cell, columns int) [][]cell { offset = lastWhitespaceIndex + 1 n = 0 for _, c := range line[offset : i+1] { - n += uniseg.StringWidth(c.chr) + n += c.width } } else { // in this case we're breaking mid-word diff --git a/view_test.go b/view_test.go index 8b5aca8b..a7023be4 100644 --- a/view_test.go +++ b/view_test.go @@ -221,8 +221,9 @@ func stringToCells(s string) []cell { state := -1 for len(s) > 0 { var c string - c, s, _, state = uniseg.FirstGraphemeClusterInString(s, state) - cells = append(cells, cell{chr: c}) + var w int + c, s, w, state = uniseg.FirstGraphemeClusterInString(s, state) + cells = append(cells, cell{chr: c, width: w}) } return cells } From faf0bad611957e967bc82aa060dd0fb85006ca7d Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sat, 13 Dec 2025 15:50:32 +0100 Subject: [PATCH 17/18] Update SimpleEditor with lazygit's additions Lazygit has had its own copy of the SimpleEditor (for no reason, really), and it has been extended a bit over time. Backport those additions to gocui's SimpleEditor, so that we can use it in lazygit and get rid of the code duplication. --- edit.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/edit.go b/edit.go index b23f0a45..012682eb 100644 --- a/edit.go +++ b/edit.go @@ -29,6 +29,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,11 +40,11 @@ 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() From 8abc274fd2eed54b555f44341c17116447719b47 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sat, 13 Dec 2025 16:01:29 +0100 Subject: [PATCH 18/18] Allow typing unprintable characters into TextArea MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This is needed for being able to "type" emojis such as 🏴󠁧󠁢󠁥󠁮󠁧󠁿; such an emoji is a grapheme cluster that consists of a base rune (🏴 in this case), and a bunch of tag sequences that are not printable when they are used on their own. It is not really possible to type such an emoji (as far as I can tell), but you can copy/paste it, in which case the individual runes of this grapheme cluster will be passed to SimpleEditor one by one, and it is important that we add them all to the text area's content string. --- edit.go | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/edit.go b/edit.go index 012682eb..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 @@ -66,7 +62,7 @@ func SimpleEditor(v *View, key Key, ch rune, mod Modifier) bool { v.TextArea.BackSpaceWord() case key == KeyCtrlY: v.TextArea.Yank() - case unicode.IsPrint(ch): + case ch != 0: v.TextArea.TypeCharacter(string(ch)) default: return false