From e5cbb074970c79b8286c615d5b5b16e72db55dd3 Mon Sep 17 00:00:00 2001 From: Mateusz Drewniak Date: Fri, 28 Jul 2023 00:30:07 +0200 Subject: [PATCH] Fix horizontal cursor movements when there are grapheme clusters --- _example/http-prompt/main.go | 2 +- _example/live-prefix/main.go | 2 +- buffer.go | 40 ++++++--- buffer_test.go | 28 +++--- document.go | 77 ++++++++++++++-- document_test.go | 166 +++++++++++++++++------------------ emacs.go | 14 +-- go.mod | 5 +- go.sum | 2 + key_bind_func.go | 6 +- position.go | 103 +++++++--------------- position_test.go | 8 ++ prompt.go | 63 +++++++------ strings/strings.go | 23 ++++- strings/strings_test.go | 31 +++++++ strings/units.go | 4 + 16 files changed, 337 insertions(+), 237 deletions(-) diff --git a/_example/http-prompt/main.go b/_example/http-prompt/main.go index df8e1091..67fbfb5e 100644 --- a/_example/http-prompt/main.go +++ b/_example/http-prompt/main.go @@ -164,7 +164,7 @@ func completer(in prompt.Document) ([]prompt.Suggest, istrings.RuneNumber, istri if w == "" { return []prompt.Suggest{}, 0, 0 } - startIndex := endIndex - istrings.RuneCount(w) + startIndex := endIndex - istrings.RuneCountInString(w) return prompt.FilterHasPrefix(suggestions, w, true), startIndex, endIndex } diff --git a/_example/live-prefix/main.go b/_example/live-prefix/main.go index d4f9cb3c..a33e1357 100644 --- a/_example/live-prefix/main.go +++ b/_example/live-prefix/main.go @@ -21,7 +21,7 @@ func executor(in string) { func completer(in prompt.Document) ([]prompt.Suggest, istrings.RuneNumber, istrings.RuneNumber) { endIndex := in.CurrentRuneIndex() w := in.GetWordBeforeCursor() - startIndex := endIndex - istrings.RuneCount(w) + startIndex := endIndex - istrings.RuneCountInString(w) s := []prompt.Suggest{ {Text: "users", Description: "Store the username and age"}, diff --git a/buffer.go b/buffer.go index 6232f132..d0fc6682 100644 --- a/buffer.go +++ b/buffer.go @@ -69,13 +69,13 @@ func (b *Buffer) insertText(text string, columns istrings.Width, rows int, overw if overwrite { overwritten := string(currentTextRunes[cursor:]) if len(overwritten) >= int(cursor)+len(text) { - overwritten = string(currentTextRunes[cursor : cursor+istrings.RuneCount(text)]) + overwritten = string(currentTextRunes[cursor : cursor+istrings.RuneCountInString(text)]) } if i := strings.IndexAny(overwritten, "\n"); i != -1 { overwritten = overwritten[:i] } b.setText( - string(currentTextRunes[:cursor])+text+string(currentTextRunes[cursor+istrings.RuneCount(overwritten):]), + string(currentTextRunes[:cursor])+text+string(currentTextRunes[cursor+istrings.RuneCountInString(overwritten):]), columns, rows, ) @@ -88,7 +88,7 @@ func (b *Buffer) insertText(text string, columns istrings.Width, rows int, overw } if moveCursor { - b.cursorPosition += istrings.RuneCount(text) + b.cursorPosition += istrings.RuneCountInString(text) b.recalculateStartLine(columns, rows) b.updatePreferredColumn() } @@ -118,7 +118,7 @@ func (b *Buffer) recalculateStartLine(columns istrings.Width, rows int) bool { // (When doing this, make sure that the cursor_position is valid for this text. // text/cursor_position should be consistent at any time, otherwise set a Document instead.) func (b *Buffer) setText(text string, col istrings.Width, row int) { - debug.Assert(b.cursorPosition <= istrings.RuneCount(text), "length of input should be shorter than cursor position") + debug.Assert(b.cursorPosition <= istrings.RuneCountInString(text), "length of input should be shorter than cursor position") b.workingLines[b.workingIndex] = text b.recalculateStartLine(col, row) b.resetPreferredColumn() @@ -141,20 +141,32 @@ func (b *Buffer) setDocument(d *Document, columns istrings.Width, rows int) { b.resetPreferredColumn() } -// Move to the left on the current line. +// Move to the left on the current line by the given amount of graphemes. // Returns true when the view should be rerendered. -func (b *Buffer) CursorLeft(count istrings.RuneNumber, columns istrings.Width, rows int) bool { - l := b.Document().GetCursorLeftPosition(count) - b.cursorPosition += l - b.updatePreferredColumn() - return b.recalculateStartLine(columns, rows) +func (b *Buffer) CursorLeft(count istrings.GraphemeNumber, columns istrings.Width, rows int) bool { + return b.cursorHorizontalMove(b.Document().GetCursorLeftPosition(count), columns, rows) +} + +// Move to the left on the current line by the given amount of runes. +// Returns true when the view should be rerendered. +func (b *Buffer) CursorLeftRunes(count istrings.RuneNumber, columns istrings.Width, rows int) bool { + return b.cursorHorizontalMove(b.Document().GetCursorLeftPositionRunes(count), columns, rows) } -// Move to the right on the current line. +// Move to the right on the current line by the given amount of graphemes. // Returns true when the view should be rerendered. -func (b *Buffer) CursorRight(count istrings.RuneNumber, columns istrings.Width, rows int) bool { - l := b.Document().GetCursorRightPosition(count) - b.cursorPosition += l +func (b *Buffer) CursorRight(count istrings.GraphemeNumber, columns istrings.Width, rows int) bool { + return b.cursorHorizontalMove(b.Document().GetCursorRightPosition(count), columns, rows) +} + +// Move to the right on the current line by the given amount of runes. +// Returns true when the view should be rerendered. +func (b *Buffer) CursorRightRunes(count istrings.RuneNumber, columns istrings.Width, rows int) bool { + return b.cursorHorizontalMove(b.Document().GetCursorRightPositionRunes(count), columns, rows) +} + +func (b *Buffer) cursorHorizontalMove(count istrings.RuneNumber, columns istrings.Width, rows int) bool { + b.cursorPosition += count b.updatePreferredColumn() return b.recalculateStartLine(columns, rows) } diff --git a/buffer_test.go b/buffer_test.go index a8fea1c0..7dd220f0 100644 --- a/buffer_test.go +++ b/buffer_test.go @@ -25,8 +25,8 @@ func TestBuffer_InsertText(t *testing.T) { t.Errorf("Text should be %#v, got %#v", "some_text", b.Text()) } - if b.cursorPosition != istrings.RuneCount("some_text") { - t.Errorf("cursorPosition should be %#v, got %#v", istrings.RuneCount("some_text"), b.cursorPosition) + if b.cursorPosition != istrings.RuneCountInString("some_text") { + t.Errorf("cursorPosition should be %#v, got %#v", istrings.RuneCountInString("some_text"), b.cursorPosition) } } @@ -38,8 +38,8 @@ func TestBuffer_InsertText_Overwrite(t *testing.T) { t.Errorf("Text should be %#v, got %#v", "ABC", b.Text()) } - if b.cursorPosition != istrings.RuneCount("ABC") { - t.Errorf("cursorPosition should be %#v, got %#v", istrings.RuneCount("ABC"), b.cursorPosition) + if b.cursorPosition != istrings.RuneCountInString("ABC") { + t.Errorf("cursorPosition should be %#v, got %#v", istrings.RuneCountInString("ABC"), b.cursorPosition) } b.CursorLeft(1, DefColCount, DefRowCount) @@ -87,8 +87,8 @@ func TestBuffer_CursorMovement(t *testing.T) { if b.Text() != "some_teAxt" { t.Errorf("Text should be %#v, got %#v", "some_teAxt", b.Text()) } - if b.cursorPosition != istrings.RuneCount("some_teA") { - t.Errorf("Text should be %#v, got %#v", istrings.RuneCount("some_teA"), b.cursorPosition) + if b.cursorPosition != istrings.RuneCountInString("some_teA") { + t.Errorf("Text should be %#v, got %#v", istrings.RuneCountInString("some_teA"), b.cursorPosition) } // Moving over left character counts. @@ -97,8 +97,8 @@ func TestBuffer_CursorMovement(t *testing.T) { if b.Text() != "Asome_teAxt" { t.Errorf("Text should be %#v, got %#v", "some_teAxt", b.Text()) } - if b.cursorPosition != istrings.RuneCount("A") { - t.Errorf("Text should be %#v, got %#v", istrings.RuneCount("some_teA"), b.cursorPosition) + if b.cursorPosition != istrings.RuneCountInString("A") { + t.Errorf("Text should be %#v, got %#v", istrings.RuneCountInString("some_teA"), b.cursorPosition) } // TODO: Going right already at right end. @@ -148,8 +148,8 @@ func TestBuffer_CursorDown(t *testing.T) { // Normally going down b.CursorDown(1, DefColCount, DefRowCount) - if b.Document().cursorPosition != istrings.RuneCount("line1\nlin") { - t.Errorf("Should be %#v, got %#v", istrings.RuneCount("line1\nlin"), b.Document().cursorPosition) + if b.Document().cursorPosition != istrings.RuneCountInString("line1\nlin") { + t.Errorf("Should be %#v, got %#v", istrings.RuneCountInString("line1\nlin"), b.Document().cursorPosition) } // Going down to a line that's storter. @@ -157,8 +157,8 @@ func TestBuffer_CursorDown(t *testing.T) { b.InsertTextMoveCursor("long line1\na\nb", DefColCount, DefRowCount, false) b.cursorPosition = 3 b.CursorDown(1, DefColCount, DefRowCount) - if b.Document().cursorPosition != istrings.RuneCount("long line1\na") { - t.Errorf("Should be %#v, got %#v", istrings.RuneCount("long line1\na"), b.Document().cursorPosition) + if b.Document().cursorPosition != istrings.RuneCountInString("long line1\na") { + t.Errorf("Should be %#v, got %#v", istrings.RuneCountInString("long line1\na"), b.Document().cursorPosition) } } @@ -174,8 +174,8 @@ func TestBuffer_DeleteBeforeCursor(t *testing.T) { if deleted != "e" { t.Errorf("Should be %#v, got %#v", deleted, "e") } - if b.cursorPosition != istrings.RuneCount("some_t") { - t.Errorf("Should be %#v, got %#v", istrings.RuneCount("some_t"), b.cursorPosition) + if b.cursorPosition != istrings.RuneCountInString("some_t") { + t.Errorf("Should be %#v, got %#v", istrings.RuneCountInString("some_t"), b.cursorPosition) } // Delete over the characters length before cursor. diff --git a/document.go b/document.go index a3ae1ff8..fad9b06b 100644 --- a/document.go +++ b/document.go @@ -7,6 +7,7 @@ import ( "github.com/elk-language/go-prompt/bisect" istrings "github.com/elk-language/go-prompt/strings" + "github.com/rivo/uniseg" "golang.org/x/exp/utf8string" ) @@ -142,7 +143,7 @@ func (d *Document) FindStartOfPreviousWord() istrings.ByteNumber { // of the text before the cursor until the start of the previous word. func (d *Document) FindRuneNumberUntilStartOfPreviousWord() istrings.RuneNumber { x := d.TextBeforeCursor() - return istrings.RuneCount(x[d.FindStartOfPreviousWordWithSpace():]) + return istrings.RuneCountInString(x[d.FindStartOfPreviousWordWithSpace():]) } // FindStartOfPreviousWordWithSpace is almost the same as FindStartOfPreviousWord. @@ -344,7 +345,7 @@ func (d *Document) CursorPositionRow() (row istrings.RuneNumber) { // TextEndPositionRow returns the row of the end of the current text. (0-based.) func (d *Document) TextEndPositionRow() (row istrings.RuneNumber) { - textLength := istrings.RuneCount(d.Text) + textLength := istrings.RuneCountInString(d.Text) if textLength == 0 { return 0 } @@ -359,11 +360,41 @@ func (d *Document) CursorPositionCol() (col istrings.RuneNumber) { return } -// GetCursorLeftPosition returns the relative position for cursor left. -func (d *Document) GetCursorLeftPosition(count istrings.RuneNumber) istrings.RuneNumber { +// Returns the amount of runes that the cursors should be moved by. +// The `count` argument tells this function by how many graphemes (visible characters) +// the cursor should be moved (to the left). +func (d *Document) GetCursorLeftPosition(count istrings.GraphemeNumber) istrings.RuneNumber { if count < 0 { return d.GetCursorRightPosition(-count) } + if d.cursorPosition == 0 { + return 0 + } + text := d.TextBeforeCursor() + g := uniseg.NewGraphemes(text) + graphemeLength := istrings.GraphemeCount(text) + var currentGraphemeIndex istrings.GraphemeNumber + var currentPosition istrings.RuneNumber + + for g.Next() { + if currentGraphemeIndex >= graphemeLength-count { + break + } + currentPosition += istrings.RuneNumber(len(g.Runes())) + currentGraphemeIndex++ + } + + result := d.cursorPosition - currentPosition + return -result +} + +// Returns the amount of runes that the cursors should be moved by. +// The `count` argument tells this function by how many runes +// the cursor should be moved (to the left). +func (d *Document) GetCursorLeftPositionRunes(count istrings.RuneNumber) istrings.RuneNumber { + if count < 0 { + return d.GetCursorRightPositionRunes(-count) + } runeSlice := []rune(d.Text) var counter istrings.RuneNumber targetPosition := d.cursorPosition - count @@ -377,11 +408,39 @@ func (d *Document) GetCursorLeftPosition(count istrings.RuneNumber) istrings.Run return counter } -// GetCursorRightPosition returns relative position for cursor right. -func (d *Document) GetCursorRightPosition(count istrings.RuneNumber) istrings.RuneNumber { +// Returns the amount of runes that the cursors should be moved by. +// The `count` argument tells this function by how many graphemes (visible characters) +// the cursor should be moved (to the right). +func (d *Document) GetCursorRightPosition(count istrings.GraphemeNumber) istrings.RuneNumber { if count < 0 { return d.GetCursorLeftPosition(-count) } + text := d.TextAfterCursor() + if len(text) == 0 { + return 0 + } + + g := uniseg.NewGraphemes(text) + var currentGraphemeIndex istrings.GraphemeNumber + var currentPosition istrings.RuneNumber + + for g.Next() { + if currentGraphemeIndex >= count { + break + } + currentPosition += istrings.RuneNumber(len(g.Runes())) + currentGraphemeIndex++ + } + return currentPosition +} + +// Returns the amount of runes that the cursors should be moved by. +// The `count` argument tells this function by how many runes +// the cursor should be moved (to the right). +func (d *Document) GetCursorRightPositionRunes(count istrings.RuneNumber) istrings.RuneNumber { + if count < 0 { + return d.GetCursorLeftPositionRunes(-count) + } runeSlice := []rune(d.Text) var counter istrings.RuneNumber targetPosition := d.cursorPosition + count @@ -496,12 +555,12 @@ func (d *Document) OnLastLine() bool { // GetEndOfLinePosition returns relative position for the end of this line. func (d *Document) GetEndOfLinePosition() istrings.RuneNumber { - return istrings.RuneCount(d.CurrentLineAfterCursor()) + return istrings.RuneCountInString(d.CurrentLineAfterCursor()) } // GetStartOfLinePosition returns relative position for the start of this line. func (d *Document) GetStartOfLinePosition() istrings.RuneNumber { - return istrings.RuneCount(d.CurrentLineBeforeCursor()) + return istrings.RuneCountInString(d.CurrentLineBeforeCursor()) } // GetStartOfLinePosition returns relative position for the start of this line. @@ -521,7 +580,7 @@ func (d *Document) FindStartOfFirstWordOfLine() istrings.RuneNumber { } if counter == 0 { - return istrings.RuneCount(line) + return istrings.RuneCountInString(line) } return counter diff --git a/document_test.go b/document_test.go index 56648d48..9c3c2db5 100644 --- a/document_test.go +++ b/document_test.go @@ -15,7 +15,7 @@ func ExampleDocument_CurrentLine() { This is a example of Document component. This component has texts displayed in terminal and cursor position. `, - cursorPosition: istrings.RuneCount(`Hello! my name is c-bata. + cursorPosition: istrings.RuneCountInString(`Hello! my name is c-bata. This is a exam`), } fmt.Println(d.CurrentLine()) @@ -26,7 +26,7 @@ This is a exam`), func ExampleDocument_DisplayCursorPosition() { d := &Document{ Text: `Hello! my name is c-bata.`, - cursorPosition: istrings.RuneCount(`Hello`), + cursorPosition: istrings.RuneCountInString(`Hello`), } fmt.Println("DisplayCursorPosition", d.DisplayCursorPosition(50)) // Output: @@ -39,7 +39,7 @@ func ExampleDocument_CursorPositionRow() { This is a example of Document component. This component has texts displayed in terminal and cursor position. `, - cursorPosition: istrings.RuneCount(`Hello! my name is c-bata. + cursorPosition: istrings.RuneCountInString(`Hello! my name is c-bata. This is a exam`), } fmt.Println("CursorPositionRow", d.CursorPositionRow()) @@ -53,7 +53,7 @@ func ExampleDocument_CursorPositionCol() { This is a example of Document component. This component has texts displayed in terminal and cursor position. `, - cursorPosition: istrings.RuneCount(`Hello! my name is c-bata. + cursorPosition: istrings.RuneCountInString(`Hello! my name is c-bata. This is a exam`), } fmt.Println("CursorPositionCol", d.CursorPositionCol()) @@ -67,7 +67,7 @@ func ExampleDocument_TextBeforeCursor() { This is a example of Document component. This component has texts displayed in terminal and cursor position. `, - cursorPosition: istrings.RuneCount(`Hello! my name is c-bata. + cursorPosition: istrings.RuneCountInString(`Hello! my name is c-bata. This is a exam`), } fmt.Println(d.TextBeforeCursor()) @@ -82,7 +82,7 @@ func ExampleDocument_TextAfterCursor() { This is a example of Document component. This component has texts displayed in terminal and cursor position. `, - cursorPosition: istrings.RuneCount(`Hello! my name is c-bata. + cursorPosition: istrings.RuneCountInString(`Hello! my name is c-bata. This is a exam`), } fmt.Println(d.TextAfterCursor()) @@ -107,7 +107,7 @@ func ExampleDocument_CurrentLineBeforeCursor() { This is a example of Document component. This component has texts displayed in terminal and cursor position. `, - cursorPosition: istrings.RuneCount(`Hello! my name is c-bata. + cursorPosition: istrings.RuneCountInString(`Hello! my name is c-bata. This is a exam`), } fmt.Println(d.CurrentLineBeforeCursor()) @@ -121,7 +121,7 @@ func ExampleDocument_CurrentLineAfterCursor() { This is a example of Document component. This component has texts displayed in terminal and cursor position. `, - cursorPosition: istrings.RuneCount(`Hello! my name is c-bata. + cursorPosition: istrings.RuneCountInString(`Hello! my name is c-bata. This is a exam`), } fmt.Println(d.CurrentLineAfterCursor()) @@ -134,7 +134,7 @@ func ExampleDocument_GetWordBeforeCursor() { Text: `Hello! my name is c-bata. This is a example of Document component. `, - cursorPosition: istrings.RuneCount(`Hello! my name is c-bata. + cursorPosition: istrings.RuneCountInString(`Hello! my name is c-bata. This is a exam`), } fmt.Println(d.GetWordBeforeCursor()) @@ -147,7 +147,7 @@ func ExampleDocument_GetWordAfterCursor() { Text: `Hello! my name is c-bata. This is a example of Document component. `, - cursorPosition: istrings.RuneCount(`Hello! my name is c-bata. + cursorPosition: istrings.RuneCountInString(`Hello! my name is c-bata. This is a exam`), } fmt.Println(d.GetWordAfterCursor()) @@ -160,7 +160,7 @@ func ExampleDocument_GetWordBeforeCursorWithSpace() { Text: `Hello! my name is c-bata. This is a example of Document component. `, - cursorPosition: istrings.RuneCount(`Hello! my name is c-bata. + cursorPosition: istrings.RuneCountInString(`Hello! my name is c-bata. This is a example `), } fmt.Println(d.GetWordBeforeCursorWithSpace()) @@ -173,7 +173,7 @@ func ExampleDocument_GetWordAfterCursorWithSpace() { Text: `Hello! my name is c-bata. This is a example of Document component. `, - cursorPosition: istrings.RuneCount(`Hello! my name is c-bata. + cursorPosition: istrings.RuneCountInString(`Hello! my name is c-bata. This is a`), } fmt.Println(d.GetWordAfterCursorWithSpace()) @@ -184,7 +184,7 @@ This is a`), func ExampleDocument_GetWordBeforeCursorUntilSeparator() { d := &Document{ Text: `hello,i am c-bata`, - cursorPosition: istrings.RuneCount(`hello,i am c`), + cursorPosition: istrings.RuneCountInString(`hello,i am c`), } fmt.Println(d.GetWordBeforeCursorUntilSeparator(",")) // Output: @@ -194,7 +194,7 @@ func ExampleDocument_GetWordBeforeCursorUntilSeparator() { func ExampleDocument_GetWordAfterCursorUntilSeparator() { d := &Document{ Text: `hello,i am c-bata,thank you for using go-prompt`, - cursorPosition: istrings.RuneCount(`hello,i a`), + cursorPosition: istrings.RuneCountInString(`hello,i a`), } fmt.Println(d.GetWordAfterCursorUntilSeparator(",")) // Output: @@ -204,7 +204,7 @@ func ExampleDocument_GetWordAfterCursorUntilSeparator() { func ExampleDocument_GetWordBeforeCursorUntilSeparatorIgnoreNextToCursor() { d := &Document{ Text: `hello,i am c-bata,thank you for using go-prompt`, - cursorPosition: istrings.RuneCount(`hello,i am c-bata,`), + cursorPosition: istrings.RuneCountInString(`hello,i am c-bata,`), } fmt.Println(d.GetWordBeforeCursorUntilSeparatorIgnoreNextToCursor(",")) // Output: @@ -214,7 +214,7 @@ func ExampleDocument_GetWordBeforeCursorUntilSeparatorIgnoreNextToCursor() { func ExampleDocument_GetWordAfterCursorUntilSeparatorIgnoreNextToCursor() { d := &Document{ Text: `hello,i am c-bata,thank you for using go-prompt`, - cursorPosition: istrings.RuneCount(`hello`), + cursorPosition: istrings.RuneCountInString(`hello`), } fmt.Println(d.GetWordAfterCursorUntilSeparatorIgnoreNextToCursor(",")) // Output: @@ -269,7 +269,7 @@ func TestDocument_GetCharRelativeToCursor(t *testing.T) { { document: &Document{ Text: "line 1\nline 2\nline 3\nline 4\n", - cursorPosition: istrings.RuneCount("line 1\n" + "lin"), + cursorPosition: istrings.RuneCountInString("line 1\n" + "lin"), }, expected: "e", }, @@ -306,7 +306,7 @@ func TestDocument_TextBeforeCursor(t *testing.T) { { document: &Document{ Text: "line 1\nline 2\nline 3\nline 4\n", - cursorPosition: istrings.RuneCount("line 1\n" + "lin"), + cursorPosition: istrings.RuneCountInString("line 1\n" + "lin"), }, expected: "line 1\nlin", }, @@ -341,7 +341,7 @@ func TestDocument_TextAfterCursor(t *testing.T) { { document: &Document{ Text: "line 1\nline 2\nline 3\nline 4\n", - cursorPosition: istrings.RuneCount("line 1\n" + "lin"), + cursorPosition: istrings.RuneCountInString("line 1\n" + "lin"), }, expected: "e 2\nline 3\nline 4\n", }, @@ -385,14 +385,14 @@ func TestDocument_GetWordBeforeCursor(t *testing.T) { { document: &Document{ Text: "apple bana", - cursorPosition: istrings.RuneCount("apple bana"), + cursorPosition: istrings.RuneCountInString("apple bana"), }, expected: "bana", }, { document: &Document{ Text: "apply -f ./file/foo.json", - cursorPosition: istrings.RuneCount("apply -f ./file/foo.json"), + cursorPosition: istrings.RuneCountInString("apply -f ./file/foo.json"), }, expected: "foo.json", sep: " /", @@ -400,14 +400,14 @@ func TestDocument_GetWordBeforeCursor(t *testing.T) { { document: &Document{ Text: "apple banana orange", - cursorPosition: istrings.RuneCount("apple ba"), + cursorPosition: istrings.RuneCountInString("apple ba"), }, expected: "ba", }, { document: &Document{ Text: "apply -f ./file/foo.json", - cursorPosition: istrings.RuneCount("apply -f ./fi"), + cursorPosition: istrings.RuneCountInString("apply -f ./fi"), }, expected: "fi", sep: " /", @@ -415,7 +415,7 @@ func TestDocument_GetWordBeforeCursor(t *testing.T) { { document: &Document{ Text: "apple ", - cursorPosition: istrings.RuneCount("apple "), + cursorPosition: istrings.RuneCountInString("apple "), }, expected: "", }, @@ -463,14 +463,14 @@ func TestDocument_GetWordBeforeCursorWithSpace(t *testing.T) { { document: &Document{ Text: "apple bana ", - cursorPosition: istrings.RuneCount("apple bana "), + cursorPosition: istrings.RuneCountInString("apple bana "), }, expected: "bana ", }, { document: &Document{ Text: "apply -f /path/to/file/", - cursorPosition: istrings.RuneCount("apply -f /path/to/file/"), + cursorPosition: istrings.RuneCountInString("apply -f /path/to/file/"), }, expected: "file/", sep: " /", @@ -478,14 +478,14 @@ func TestDocument_GetWordBeforeCursorWithSpace(t *testing.T) { { document: &Document{ Text: "apple ", - cursorPosition: istrings.RuneCount("apple "), + cursorPosition: istrings.RuneCountInString("apple "), }, expected: "apple ", }, { document: &Document{ Text: "path/", - cursorPosition: istrings.RuneCount("path/"), + cursorPosition: istrings.RuneCountInString("path/"), }, expected: "path/", sep: " /", @@ -534,14 +534,14 @@ func TestDocument_FindStartOfPreviousWord(t *testing.T) { { document: &Document{ Text: "apple bana", - cursorPosition: istrings.RuneCount("apple bana"), + cursorPosition: istrings.RuneCountInString("apple bana"), }, expected: istrings.Len("apple "), }, { document: &Document{ Text: "apply -f ./file/foo.json", - cursorPosition: istrings.RuneCount("apply -f ./file/foo.json"), + cursorPosition: istrings.RuneCountInString("apply -f ./file/foo.json"), }, expected: istrings.Len("apply -f ./file/"), sep: " /", @@ -549,14 +549,14 @@ func TestDocument_FindStartOfPreviousWord(t *testing.T) { { document: &Document{ Text: "apple ", - cursorPosition: istrings.RuneCount("apple "), + cursorPosition: istrings.RuneCountInString("apple "), }, expected: istrings.Len("apple "), }, { document: &Document{ Text: "apply -f ./file/foo.json", - cursorPosition: istrings.RuneCount("apply -f ./"), + cursorPosition: istrings.RuneCountInString("apply -f ./"), }, expected: istrings.Len("apply -f ./"), sep: " /", @@ -605,14 +605,14 @@ func TestDocument_FindStartOfPreviousWordWithSpace(t *testing.T) { { document: &Document{ Text: "apple bana ", - cursorPosition: istrings.RuneCount("apple bana "), + cursorPosition: istrings.RuneCountInString("apple bana "), }, expected: istrings.Len("apple "), }, { document: &Document{ Text: "apply -f /file/foo/", - cursorPosition: istrings.RuneCount("apply -f /file/foo/"), + cursorPosition: istrings.RuneCountInString("apply -f /file/foo/"), }, expected: istrings.Len("apply -f /file/"), sep: " /", @@ -620,14 +620,14 @@ func TestDocument_FindStartOfPreviousWordWithSpace(t *testing.T) { { document: &Document{ Text: "apple ", - cursorPosition: istrings.RuneCount("apple "), + cursorPosition: istrings.RuneCountInString("apple "), }, expected: istrings.Len(""), }, { document: &Document{ Text: "file/", - cursorPosition: istrings.RuneCount("file/"), + cursorPosition: istrings.RuneCountInString("file/"), }, expected: istrings.Len(""), sep: " /", @@ -676,14 +676,14 @@ func TestDocument_GetWordAfterCursor(t *testing.T) { { document: &Document{ Text: "apple bana", - cursorPosition: istrings.RuneCount("apple bana"), + cursorPosition: istrings.RuneCountInString("apple bana"), }, expected: "", }, { document: &Document{ Text: "apply -f ./file/foo.json", - cursorPosition: istrings.RuneCount("apply -f ./fi"), + cursorPosition: istrings.RuneCountInString("apply -f ./fi"), }, expected: "le", sep: " /", @@ -691,21 +691,21 @@ func TestDocument_GetWordAfterCursor(t *testing.T) { { document: &Document{ Text: "apple bana", - cursorPosition: istrings.RuneCount("apple "), + cursorPosition: istrings.RuneCountInString("apple "), }, expected: "bana", }, { document: &Document{ Text: "apple bana", - cursorPosition: istrings.RuneCount("apple"), + cursorPosition: istrings.RuneCountInString("apple"), }, expected: "", }, { document: &Document{ Text: "apply -f ./file/foo.json", - cursorPosition: istrings.RuneCount("apply -f ."), + cursorPosition: istrings.RuneCountInString("apply -f ."), }, expected: "", sep: " /", @@ -713,7 +713,7 @@ func TestDocument_GetWordAfterCursor(t *testing.T) { { document: &Document{ Text: "apple bana", - cursorPosition: istrings.RuneCount("ap"), + cursorPosition: istrings.RuneCountInString("ap"), }, expected: "ple", }, @@ -761,21 +761,21 @@ func TestDocument_GetWordAfterCursorWithSpace(t *testing.T) { { document: &Document{ Text: "apple bana", - cursorPosition: istrings.RuneCount("apple bana"), + cursorPosition: istrings.RuneCountInString("apple bana"), }, expected: "", }, { document: &Document{ Text: "apple bana", - cursorPosition: istrings.RuneCount("apple "), + cursorPosition: istrings.RuneCountInString("apple "), }, expected: "bana", }, { document: &Document{ Text: "/path/to", - cursorPosition: istrings.RuneCount("/path/"), + cursorPosition: istrings.RuneCountInString("/path/"), }, expected: "to", sep: " /", @@ -783,7 +783,7 @@ func TestDocument_GetWordAfterCursorWithSpace(t *testing.T) { { document: &Document{ Text: "/path/to/file", - cursorPosition: istrings.RuneCount("/path/"), + cursorPosition: istrings.RuneCountInString("/path/"), }, expected: "to", sep: " /", @@ -791,14 +791,14 @@ func TestDocument_GetWordAfterCursorWithSpace(t *testing.T) { { document: &Document{ Text: "apple bana", - cursorPosition: istrings.RuneCount("apple"), + cursorPosition: istrings.RuneCountInString("apple"), }, expected: " bana", }, { document: &Document{ Text: "path/to", - cursorPosition: istrings.RuneCount("path"), + cursorPosition: istrings.RuneCountInString("path"), }, expected: "/to", sep: " /", @@ -806,7 +806,7 @@ func TestDocument_GetWordAfterCursorWithSpace(t *testing.T) { { document: &Document{ Text: "apple bana", - cursorPosition: istrings.RuneCount("ap"), + cursorPosition: istrings.RuneCountInString("ap"), }, expected: "ple", }, @@ -854,21 +854,21 @@ func TestDocument_FindEndOfCurrentWord(t *testing.T) { { document: &Document{ Text: "apple bana", - cursorPosition: istrings.RuneCount("apple bana"), + cursorPosition: istrings.RuneCountInString("apple bana"), }, expected: istrings.Len(""), }, { document: &Document{ Text: "apple bana", - cursorPosition: istrings.RuneCount("apple "), + cursorPosition: istrings.RuneCountInString("apple "), }, expected: istrings.Len("bana"), }, { document: &Document{ Text: "apply -f ./file/foo.json", - cursorPosition: istrings.RuneCount("apply -f ./"), + cursorPosition: istrings.RuneCountInString("apply -f ./"), }, expected: istrings.Len("file"), sep: " /", @@ -876,14 +876,14 @@ func TestDocument_FindEndOfCurrentWord(t *testing.T) { { document: &Document{ Text: "apple bana", - cursorPosition: istrings.RuneCount("apple"), + cursorPosition: istrings.RuneCountInString("apple"), }, expected: istrings.Len(""), }, { document: &Document{ Text: "apply -f ./file/foo.json", - cursorPosition: istrings.RuneCount("apply -f ."), + cursorPosition: istrings.RuneCountInString("apply -f ."), }, expected: istrings.Len(""), sep: " /", @@ -891,7 +891,7 @@ func TestDocument_FindEndOfCurrentWord(t *testing.T) { { document: &Document{ Text: "apple bana", - cursorPosition: istrings.RuneCount("ap"), + cursorPosition: istrings.RuneCountInString("ap"), }, expected: istrings.Len("ple"), }, @@ -948,21 +948,21 @@ func TestDocument_FindEndOfCurrentWordWithSpace(t *testing.T) { { document: &Document{ Text: "apple bana", - cursorPosition: istrings.RuneCount("apple bana"), + cursorPosition: istrings.RuneCountInString("apple bana"), }, expected: istrings.Len(""), }, { document: &Document{ Text: "apple bana", - cursorPosition: istrings.RuneCount("apple "), + cursorPosition: istrings.RuneCountInString("apple "), }, expected: istrings.Len("bana"), }, { document: &Document{ Text: "apply -f /file/foo.json", - cursorPosition: istrings.RuneCount("apply -f /"), + cursorPosition: istrings.RuneCountInString("apply -f /"), }, expected: istrings.Len("file"), sep: " /", @@ -970,14 +970,14 @@ func TestDocument_FindEndOfCurrentWordWithSpace(t *testing.T) { { document: &Document{ Text: "apple bana", - cursorPosition: istrings.RuneCount("apple"), + cursorPosition: istrings.RuneCountInString("apple"), }, expected: istrings.Len(" bana"), }, { document: &Document{ Text: "apply -f /path/to", - cursorPosition: istrings.RuneCount("apply -f /path"), + cursorPosition: istrings.RuneCountInString("apply -f /path"), }, expected: istrings.Len("/to"), sep: " /", @@ -985,7 +985,7 @@ func TestDocument_FindEndOfCurrentWordWithSpace(t *testing.T) { { document: &Document{ Text: "apple bana", - cursorPosition: istrings.RuneCount("ap"), + cursorPosition: istrings.RuneCountInString("ap"), }, expected: istrings.Len("ple"), }, @@ -1034,7 +1034,7 @@ func TestDocument_FindEndOfCurrentWordWithSpace(t *testing.T) { func TestDocument_CurrentLineBeforeCursor(t *testing.T) { d := &Document{ Text: "line 1\nline 2\nline 3\nline 4\n", - cursorPosition: istrings.RuneCount("line 1\n" + "lin"), + cursorPosition: istrings.RuneCountInString("line 1\n" + "lin"), } ac := d.CurrentLineBeforeCursor() ex := "lin" @@ -1046,7 +1046,7 @@ func TestDocument_CurrentLineBeforeCursor(t *testing.T) { func TestDocument_CurrentLineAfterCursor(t *testing.T) { d := &Document{ Text: "line 1\nline 2\nline 3\nline 4\n", - cursorPosition: istrings.RuneCount("line 1\n" + "lin"), + cursorPosition: istrings.RuneCountInString("line 1\n" + "lin"), } ac := d.CurrentLineAfterCursor() ex := "e 2" @@ -1058,7 +1058,7 @@ func TestDocument_CurrentLineAfterCursor(t *testing.T) { func TestDocument_CurrentLine(t *testing.T) { d := &Document{ Text: "line 1\nline 2\nline 3\nline 4\n", - cursorPosition: istrings.RuneCount("line 1\n" + "lin"), + cursorPosition: istrings.RuneCountInString("line 1\n" + "lin"), } ac := d.CurrentLine() ex := "line 2" @@ -1074,7 +1074,7 @@ func TestDocument_CursorPositionRowAndCol(t *testing.T) { expectedCol istrings.RuneNumber }{ { - document: &Document{Text: "line 1\nline 2\nline 3\n", cursorPosition: istrings.RuneCount("line 1\n" + "lin")}, + document: &Document{Text: "line 1\nline 2\nline 3\n", cursorPosition: istrings.RuneCountInString("line 1\n" + "lin")}, expectedRow: 1, expectedCol: 3, }, @@ -1099,7 +1099,7 @@ func TestDocument_CursorPositionRowAndCol(t *testing.T) { func TestDocument_GetCursorLeftPosition(t *testing.T) { d := &Document{ Text: "line 1\nline 2\nline 3\nline 4\n", - cursorPosition: istrings.RuneCount("line 1\n" + "line 2\n" + "lin"), + cursorPosition: istrings.RuneCountInString("line 1\n" + "line 2\n" + "lin"), } ac := d.GetCursorLeftPosition(2) var ex istrings.RuneNumber = -2 @@ -1122,16 +1122,16 @@ func TestDocument_GetCursorLeftPosition(t *testing.T) { func TestDocument_GetCursorUpPosition(t *testing.T) { d := &Document{ Text: "line 1\nline 2\nline 3\nline 4\n", - cursorPosition: istrings.RuneCount("line 1\n" + "line 2\n" + "lin"), + cursorPosition: istrings.RuneCountInString("line 1\n" + "line 2\n" + "lin"), } ac := d.GetCursorUpPosition(2, -1) - ex := istrings.RuneCount("lin") - istrings.RuneCount("line 1\n"+"line 2\n"+"lin") + ex := istrings.RuneCountInString("lin") - istrings.RuneCountInString("line 1\n"+"line 2\n"+"lin") if ac != ex { t.Errorf("Should be %#v, got %#v", ex, ac) } ac = d.GetCursorUpPosition(100, -1) - ex = istrings.RuneCount("lin") - istrings.RuneCount("line 1\n"+"line 2\n"+"lin") + ex = istrings.RuneCountInString("lin") - istrings.RuneCountInString("line 1\n"+"line 2\n"+"lin") if ac != ex { t.Errorf("Should be %#v, got %#v", ex, ac) } @@ -1140,16 +1140,16 @@ func TestDocument_GetCursorUpPosition(t *testing.T) { func TestDocument_GetCursorDownPosition(t *testing.T) { d := &Document{ Text: "line 1\nline 2\nline 3\nline 4\n", - cursorPosition: istrings.RuneCount("lin"), + cursorPosition: istrings.RuneCountInString("lin"), } ac := d.GetCursorDownPosition(2, -1) - ex := istrings.RuneCount("line 1\n"+"line 2\n"+"lin") - istrings.RuneCount("lin") + ex := istrings.RuneCountInString("line 1\n"+"line 2\n"+"lin") - istrings.RuneCountInString("lin") if ac != ex { t.Errorf("Should be %#v, got %#v", ex, ac) } ac = d.GetCursorDownPosition(100, -1) - ex = istrings.RuneCount("line 1\n"+"line 2\n"+"line 3\n"+"line 4\n") - istrings.RuneCount("lin") + ex = istrings.RuneCountInString("line 1\n"+"line 2\n"+"line 3\n"+"line 4\n") - istrings.RuneCountInString("lin") if ac != ex { t.Errorf("Should be %#v, got %#v", ex, ac) } @@ -1158,7 +1158,7 @@ func TestDocument_GetCursorDownPosition(t *testing.T) { func TestDocument_GetCursorRightPosition(t *testing.T) { d := &Document{ Text: "line 1\nline 2\nline 3\nline 4\n", - cursorPosition: istrings.RuneCount("line 1\n" + "line 2\n" + "lin"), + cursorPosition: istrings.RuneCountInString("line 1\n" + "line 2\n" + "lin"), } ac := d.GetCursorRightPosition(2) var ex istrings.RuneNumber = 2 @@ -1181,7 +1181,7 @@ func TestDocument_GetCursorRightPosition(t *testing.T) { func TestDocument_Lines(t *testing.T) { d := &Document{ Text: "line 1\nline 2\nline 3\nline 4\n", - cursorPosition: istrings.RuneCount("line 1\n" + "lin"), + cursorPosition: istrings.RuneCountInString("line 1\n" + "lin"), } ac := d.Lines() ex := []string{"line 1", "line 2", "line 3", "line 4", ""} @@ -1193,7 +1193,7 @@ func TestDocument_Lines(t *testing.T) { func TestDocument_LineCount(t *testing.T) { d := &Document{ Text: "line 1\nline 2\nline 3\nline 4\n", - cursorPosition: istrings.RuneCount("line 1\n" + "lin"), + cursorPosition: istrings.RuneCountInString("line 1\n" + "lin"), } ac := d.LineCount() ex := 5 @@ -1205,9 +1205,9 @@ func TestDocument_LineCount(t *testing.T) { func TestDocument_TranslateIndexToPosition(t *testing.T) { d := &Document{ Text: "line 1\nline 2\nline 3\nline 4\n", - cursorPosition: istrings.RuneCount("line 1\n" + "lin"), + cursorPosition: istrings.RuneCountInString("line 1\n" + "lin"), } - row, col := d.TranslateIndexToPosition(istrings.RuneCount("line 1\nline 2\nlin")) + row, col := d.TranslateIndexToPosition(istrings.RuneCountInString("line 1\nline 2\nlin")) if row != 2 { t.Errorf("Should be %#v, got %#v", 2, row) } @@ -1226,10 +1226,10 @@ func TestDocument_TranslateIndexToPosition(t *testing.T) { func TestDocument_TranslateRowColToIndex(t *testing.T) { d := &Document{ Text: "line 1\nline 2\nline 3\nline 4\n", - cursorPosition: istrings.RuneCount("line 1\n" + "lin"), + cursorPosition: istrings.RuneCountInString("line 1\n" + "lin"), } ac := d.TranslateRowColToIndex(2, 3) - ex := istrings.RuneCount("line 1\nline 2\nlin") + ex := istrings.RuneCountInString("line 1\nline 2\nlin") if ac != ex { t.Errorf("Should be %#v, got %#v", ex, ac) } @@ -1243,13 +1243,13 @@ func TestDocument_TranslateRowColToIndex(t *testing.T) { func TestDocument_OnLastLine(t *testing.T) { d := &Document{ Text: "line 1\nline 2\nline 3", - cursorPosition: istrings.RuneCount("line 1\nline"), + cursorPosition: istrings.RuneCountInString("line 1\nline"), } ac := d.OnLastLine() if ac { t.Errorf("Should be %#v, got %#v", false, ac) } - d.cursorPosition = istrings.RuneCount("line 1\nline 2\nline") + d.cursorPosition = istrings.RuneCountInString("line 1\nline 2\nline") ac = d.OnLastLine() if !ac { t.Errorf("Should be %#v, got %#v", true, ac) @@ -1259,10 +1259,10 @@ func TestDocument_OnLastLine(t *testing.T) { func TestDocument_GetEndOfLinePosition(t *testing.T) { d := &Document{ Text: "line 1\nline 2\nline 3", - cursorPosition: istrings.RuneCount("line 1\nli"), + cursorPosition: istrings.RuneCountInString("line 1\nli"), } ac := d.GetEndOfLinePosition() - ex := istrings.RuneCount("ne 2") + ex := istrings.RuneCountInString("ne 2") if ac != ex { t.Errorf("Should be %#v, got %#v", ex, ac) } diff --git a/emacs.go b/emacs.go index 09098b7a..223646f4 100644 --- a/emacs.go +++ b/emacs.go @@ -46,8 +46,8 @@ var emacsKeyBindings = []KeyBind{ { Key: ControlE, Fn: func(p *Prompt) bool { - return p.CursorRight( - istrings.RuneCount(p.Buffer.Document().CurrentLineAfterCursor()), + return p.CursorRightRunes( + istrings.RuneCountInString(p.Buffer.Document().CurrentLineAfterCursor()), ) }, }, @@ -55,7 +55,7 @@ var emacsKeyBindings = []KeyBind{ { Key: ControlA, Fn: func(p *Prompt) bool { - return p.CursorLeft( + return p.CursorLeftRunes( p.Buffer.Document().FindStartOfFirstWordOfLine(), ) }, @@ -65,7 +65,7 @@ var emacsKeyBindings = []KeyBind{ Key: ControlK, Fn: func(p *Prompt) bool { p.Buffer.Delete( - istrings.RuneCount(p.Buffer.Document().CurrentLineAfterCursor()), + istrings.RuneCountInString(p.Buffer.Document().CurrentLineAfterCursor()), p.renderer.col, p.renderer.row, ) @@ -77,7 +77,7 @@ var emacsKeyBindings = []KeyBind{ Key: ControlU, Fn: func(p *Prompt) bool { p.Buffer.DeleteBeforeCursor( - istrings.RuneCount(p.Buffer.Document().CurrentLineBeforeCursor()), + istrings.RuneCountInString(p.Buffer.Document().CurrentLineBeforeCursor()), p.renderer.col, p.renderer.row, ) @@ -114,7 +114,7 @@ var emacsKeyBindings = []KeyBind{ { Key: AltRight, Fn: func(p *Prompt) bool { - return p.CursorRight( + return p.CursorRightRunes( p.Buffer.Document().FindRuneNumberUntilEndOfCurrentWord(), ) }, @@ -130,7 +130,7 @@ var emacsKeyBindings = []KeyBind{ { Key: AltLeft, Fn: func(p *Prompt) bool { - return p.CursorLeft( + return p.CursorLeftRunes( p.Buffer.Document().FindRuneNumberUntilStartOfPreviousWord(), ) }, diff --git a/go.mod b/go.mod index 0db5cac3..5d8cd258 100644 --- a/go.mod +++ b/go.mod @@ -12,4 +12,7 @@ require ( golang.org/x/sys v0.1.0 ) -require github.com/mattn/go-isatty v0.0.12 // indirect +require ( + github.com/mattn/go-isatty v0.0.12 // indirect + github.com/rivo/uniseg v0.4.4 // indirect +) diff --git a/go.sum b/go.sum index 8b177a46..03984c81 100644 --- a/go.sum +++ b/go.sum @@ -14,6 +14,8 @@ github.com/mattn/go-tty v0.0.3 h1:5OfyWorkyO7xP52Mq7tB36ajHDG5OHrmBGIS/DtakQI= github.com/mattn/go-tty v0.0.3/go.mod h1:ihxohKRERHTVzN+aSVRwACLCeqIoZAWpoICkkvrWyR0= github.com/pkg/term v1.2.0-beta.2 h1:L3y/h2jkuBVFdWiJvNfYfKmzcCnILw7mJWm2JQuMppw= github.com/pkg/term v1.2.0-beta.2/go.mod h1:E25nymQcrSllhX42Ok8MRm1+hyBdHY0dCeiKZ9jpNGw= +github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis= +github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= golang.org/x/exp v0.0.0-20230626212559-97b1e661b5df h1:UA2aFVmmsIlefxMk29Dp2juaUSth8Pyn3Tq5Y5mJGME= golang.org/x/exp v0.0.0-20230626212559-97b1e661b5df/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= diff --git a/key_bind_func.go b/key_bind_func.go index 21e203bd..59d4b499 100644 --- a/key_bind_func.go +++ b/key_bind_func.go @@ -7,13 +7,13 @@ import ( // GoLineEnd Go to the End of the line func GoLineEnd(p *Prompt) bool { x := []rune(p.Buffer.Document().TextAfterCursor()) - return p.CursorRight(istrings.RuneNumber(len(x))) + return p.CursorRightRunes(istrings.RuneNumber(len(x))) } // GoLineBeginning Go to the beginning of the line func GoLineBeginning(p *Prompt) bool { x := []rune(p.Buffer.Document().TextBeforeCursor()) - return p.CursorLeft(istrings.RuneNumber(len(x))) + return p.CursorLeftRunes(istrings.RuneNumber(len(x))) } // DeleteChar Delete character under the cursor @@ -40,7 +40,7 @@ func GoLeftChar(p *Prompt) bool { func DeleteWordBeforeCursor(p *Prompt) bool { p.Buffer.DeleteBeforeCursor( - istrings.RuneCount(p.Buffer.Document().GetWordBeforeCursorWithSpace()), + istrings.RuneCountInString(p.Buffer.Document().GetWordBeforeCursorWithSpace()), p.renderer.col, p.renderer.row, ) diff --git a/position.go b/position.go index 138e9243..53e750d2 100644 --- a/position.go +++ b/position.go @@ -1,11 +1,8 @@ package prompt import ( - "io" - "strings" - istrings "github.com/elk-language/go-prompt/strings" - "github.com/mattn/go-runewidth" + "github.com/rivo/uniseg" ) // Position stores the coordinates @@ -48,49 +45,32 @@ func (p Position) Subtract(other Position) Position { // positionAtEndOfString calculates the position of the // p at the end of the given string. func positionAtEndOfStringLine(str string, columns istrings.Width, line int) Position { - return positionAtEndOfReaderLine(strings.NewReader(str), columns, line) -} - -// positionAtEndOfString calculates the position -// at the end of the given string. -func positionAtEndOfString(str string, columns istrings.Width) Position { - return positionAtEndOfReader(strings.NewReader(str), columns) -} - -// positionAtEndOfReader calculates the position -// at the end of the given io.Reader. -func positionAtEndOfReader(reader io.RuneReader, columns istrings.Width) Position { var down int var right istrings.Width + g := uniseg.NewGraphemes(str) charLoop: - for { - char, _, err := reader.ReadRune() - if err != nil { - break charLoop - } + for g.Next() { + runes := g.Runes() - switch char { - case '\r': - char, _, err := reader.ReadRune() - if err != nil { + if len(runes) == 1 && runes[0] == '\n' { + if down == line { break charLoop } - - if char == '\n' { - down++ - right = 0 - } - case '\n': down++ right = 0 - default: - right += istrings.Width(runewidth.RuneWidth(char)) - if right == columns { - right = 0 - down++ + } + + right += istrings.Width(g.Width()) + if right > columns { + if down == line { + right = columns - 1 + break charLoop } + right = istrings.Width(g.Width()) + down++ } + } return Position{ @@ -99,49 +79,26 @@ charLoop: } } -// positionAtEndOfReaderLine calculates the position -// at the given line of the given io.Reader. -func positionAtEndOfReaderLine(reader io.RuneReader, columns istrings.Width, line int) Position { +// positionAtEndOfString calculates the position +// at the end of the given string. +func positionAtEndOfString(str string, columns istrings.Width) Position { var down int var right istrings.Width + g := uniseg.NewGraphemes(str) -charLoop: - for { - char, _, err := reader.ReadRune() - if err != nil { - break charLoop - } + for g.Next() { + runes := g.Runes() - switch char { - case '\r': - char, _, err := reader.ReadRune() - if err != nil { - break charLoop - } - - if char == '\n' { - if down == line { - break charLoop - } - down++ - right = 0 - } - case '\n': - if down == line { - break charLoop - } + if len(runes) == 1 && runes[0] == '\n' { down++ right = 0 - default: - right += istrings.Width(runewidth.RuneWidth(char)) - if right > columns { - if down == line { - right = columns - 1 - break charLoop - } - right = istrings.Width(runewidth.RuneWidth(char)) - down++ - } + continue + } + + right += istrings.Width(g.Width()) + if right == columns { + right = 0 + down++ } } diff --git a/position_test.go b/position_test.go index 0ea6dd31..6220bd26 100644 --- a/position_test.go +++ b/position_test.go @@ -40,6 +40,14 @@ func TestPositionAtEndOfString(t *testing.T) { Y: 0, }, }, + "complex emoji": { + input: "🙆🏿‍♂️", + columns: 20, + want: Position{ + X: 2, + Y: 0, + }, + }, "one-line fits in columns": { input: "foo bar", columns: 20, diff --git a/prompt.go b/prompt.go index bebc1cfa..89b47e0a 100644 --- a/prompt.go +++ b/prompt.go @@ -2,6 +2,8 @@ package prompt import ( "bytes" + "fmt" + "log" "os" "strings" "time" @@ -138,14 +140,14 @@ func (p *Prompt) Run() { } } -// func Log(format string, a ...any) { -// f, err := os.OpenFile("log", os.O_RDWR|os.O_CREATE|os.O_APPEND, 0666) -// if err != nil { -// log.Fatalf("error opening file: %v", err) -// } -// defer f.Close() -// fmt.Fprintf(f, format+"\n", a...) -// } +func Log(format string, a ...any) { + f, err := os.OpenFile("log", os.O_RDWR|os.O_CREATE|os.O_APPEND, 0666) + if err != nil { + log.Fatalf("error opening file: %v", err) + } + defer f.Close() + fmt.Fprintf(f, format+"\n", a...) +} func (p *Prompt) feed(b []byte) (shouldExit bool, rerender bool, userInput *UserInput) { key := GetKey(b) @@ -349,7 +351,7 @@ func (p *Prompt) updateSuggestions(fn func()) { // delete the previous selection if !newSelected { p.Buffer.DeleteBeforeCursor( - istrings.RuneCount(prevSuggestion.Text)-(prevEnd-prevStart), + istrings.RuneCountInString(prevSuggestion.Text)-(prevEnd-prevStart), cols, rows, ) @@ -358,7 +360,7 @@ func (p *Prompt) updateSuggestions(fn func()) { // delete previous selection and render the new one p.Buffer.DeleteBeforeCursor( - istrings.RuneCount(prevSuggestion.Text), + istrings.RuneCountInString(prevSuggestion.Text), cols, rows, ) @@ -518,33 +520,40 @@ func (p *Prompt) setup() { p.renderer.UpdateWinSize(p.reader.GetWinSize()) } -// Move to the left on the current line. +// Move to the left on the current line by the given amount of graphemes (visible characters). // Returns true when the view should be rerendered. -func (p *Prompt) CursorLeft(count istrings.RuneNumber) bool { - b := p.Buffer - cols := p.renderer.UserInputColumns() - previousCursor := b.DisplayCursorPosition(cols) +func (p *Prompt) CursorLeft(count istrings.GraphemeNumber) bool { + return promptCursorHorizontalMove(p, p.Buffer.CursorLeft, count) +} - rerender := p.Buffer.CursorLeft(count, cols, p.renderer.row) || p.completionReset || len(p.completion.tmp) > 0 - if rerender { - return true - } +// Move to the left on the current line by the given amount of runes. +// Returns true when the view should be rerendered. +func (p *Prompt) CursorLeftRunes(count istrings.RuneNumber) bool { + return promptCursorHorizontalMove(p, p.Buffer.CursorLeftRunes, count) +} - newCursor := b.DisplayCursorPosition(cols) - p.renderer.previousCursor = newCursor - p.renderer.move(previousCursor, newCursor) - p.renderer.flush() - return false +// Move the cursor to the right on the current line by the given amount of graphemes (visible characters). +// Returns true when the view should be rerendered. +func (p *Prompt) CursorRight(count istrings.GraphemeNumber) bool { + return promptCursorHorizontalMove(p, p.Buffer.CursorRight, count) } -// Move the cursor to the right on the current line. +// Move the cursor to the right on the current line by the given amount of runes. +// Returns true when the view should be rerendered. +func (p *Prompt) CursorRightRunes(count istrings.RuneNumber) bool { + return promptCursorHorizontalMove(p, p.Buffer.CursorRightRunes, count) +} + +type horizontalCursorModifier[CountT ~int] func(CountT, istrings.Width, int) bool + +// Move to the left or right on the current line. // Returns true when the view should be rerendered. -func (p *Prompt) CursorRight(count istrings.RuneNumber) bool { +func promptCursorHorizontalMove[CountT ~int](p *Prompt, modifierFunc horizontalCursorModifier[CountT], count CountT) bool { b := p.Buffer cols := p.renderer.UserInputColumns() previousCursor := b.DisplayCursorPosition(cols) - rerender := p.Buffer.CursorRight(count, cols, p.renderer.row) || p.completionReset || len(p.completion.tmp) > 0 + rerender := modifierFunc(count, cols, p.renderer.row) || p.completionReset || len(p.completion.tmp) > 0 if rerender { return true } diff --git a/strings/strings.go b/strings/strings.go index 8523faf8..0781b02a 100644 --- a/strings/strings.go +++ b/strings/strings.go @@ -4,6 +4,7 @@ import ( "unicode/utf8" "github.com/mattn/go-runewidth" + "github.com/rivo/uniseg" ) // Get the length of the string in bytes. @@ -12,13 +13,27 @@ func Len(s string) ByteNumber { } // Get the length of the string in runes. -func RuneCount(s string) RuneNumber { +func RuneCountInString(s string) RuneNumber { return RuneNumber(utf8.RuneCountInString(s)) } -// Get the width of the string (how many columns it takes upt in the terminal). -func GetWidth(s string) Width { - return Width(runewidth.StringWidth(s)) +// Get the length of the byte slice in runes. +func RuneCount(b []byte) RuneNumber { + return RuneNumber(utf8.RuneCount(b)) +} + +// Returns the number of horizontal cells needed to print the given +// text. It splits the text into its grapheme clusters, calculates each +// cluster's width, and adds them up to a total. +func GetWidth(text string) Width { + return Width(uniseg.StringWidth(text)) +} + +// Returns the number of horizontal cells needed to print the given +// text. It splits the text into its grapheme clusters, calculates each +// cluster's width, and adds them up to a total. +func GraphemeCount(text string) GraphemeNumber { + return GraphemeNumber(uniseg.GraphemeClusterCount(text)) } // Get the width of the rune (how many columns it takes upt in the terminal). diff --git a/strings/strings_test.go b/strings/strings_test.go index 8266054c..849cb2fc 100644 --- a/strings/strings_test.go +++ b/strings/strings_test.go @@ -2,10 +2,41 @@ package strings_test import ( "fmt" + "testing" "github.com/elk-language/go-prompt/strings" ) +func TestGetWidth(t *testing.T) { + tests := []struct { + in string + want strings.Width + }{ + { + in: "foo", + want: 3, + }, + { + in: "🇵🇱", + want: 2, + }, + { + in: "🙆🏿‍♂️", + want: 2, + }, + { + in: "日本語", + want: 6, + }, + } + + for _, tc := range tests { + if got := strings.GetWidth(tc.in); got != tc.want { + t.Errorf("Should be %#v, but got %#v, for %#v", tc.want, got, tc.in) + } + } +} + func ExampleIndexNotByte() { fmt.Println(strings.IndexNotByte("golang", 'g')) fmt.Println(strings.IndexNotByte("golang", 'x')) diff --git a/strings/units.go b/strings/units.go index 2f74a0e9..e34b247b 100644 --- a/strings/units.go +++ b/strings/units.go @@ -8,6 +8,10 @@ type ByteNumber int // of runes in a string, array or slice. type RuneNumber int +// Numeric type that represents the amount +// of grapheme clusters in a string, array or slice. +type GraphemeNumber int + // Numeric type that represents the visible // width of characters in a string as seen in a terminal emulator. type Width int