From e5cbb074970c79b8286c615d5b5b16e72db55dd3 Mon Sep 17 00:00:00 2001 From: Mateusz Drewniak Date: Fri, 28 Jul 2023 00:30:07 +0200 Subject: [PATCH 1/3] 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 From 6723651d7973703b633c6637d8e755740d944e8a Mon Sep 17 00:00:00 2001 From: Mateusz Drewniak Date: Fri, 28 Jul 2023 17:38:07 +0200 Subject: [PATCH 2/3] Fix vertical cursor movement --- CHANGELOG.md | 49 +++++++++++++ _example/automatic-indenter/main.go | 3 +- _example/bang-executor/main.go | 3 +- _example/simple-echo/cjk-cyrillic/main.go | 9 ++- _example/simple-echo/main.go | 10 ++- buffer.go | 88 +++++++++++++++++++---- constructor.go | 2 +- document.go | 77 +++++++++++--------- document_test.go | 58 +++++++++++++-- emacs.go | 4 +- key_bind_func.go | 2 +- prompt.go | 19 +++-- strings/strings.go | 39 ++++++++++ strings/strings_test.go | 80 +++++++++++++++++++++ 14 files changed, 370 insertions(+), 73 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 76295b26..0ffb3f03 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,55 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.1.0] - 28.07.2023 + +[Diff](https://github.com/elk-language/go-prompt/compare/v1.0.3...elk-language:go-prompt:v1.1.0) + +### Fixed +- Fix cursor movement for text with grapheme clusters like 🇵🇱, 🙆🏿‍♂️ + +### Added +- Add `strings.GraphemeNumber`, a type that represents the amount of grapheme clusters in a string (or an offset of a grapheme cluster in a string) + - `type strings.GraphemeNumber int` +- `func strings.RuneIndexNthGrapheme(text string, n strings.GraphemeNumber) strings.RuneNumber` +- `func strings.RuneIndexNthColumn(text string, n strings.Width) strings.RuneNumber` +- `func (*prompt.Document).GetCursorLeftPositionRunes(count strings.RuneNumber) strings.RuneNumber` +- `func (*prompt.Document).GetCursorRightPositionRunes(count strings.RuneNumber) strings.RuneNumber` +- `func (*prompt.Document).LastLineIndentLevel(indentSize int) int` +- `func (*prompt.Document).LastLineIndentSpaces() int` +- `func (*prompt.Buffer).DeleteRunes(count strings.RuneNumber, col strings.Width, row int) string` +- `func (*prompt.Buffer).DeleteBeforeCursorRunes(count strings.RuneNumber, col strings.Width, row int) string` + +### Changed +- Change signatures: + - `prompt.ExecuteOnEnterCallback` + - from `func(input string, indentSize int) (indent int, execute bool)` + - to `func(buffer *prompt.Buffer, indentSize int) (indent int, execute bool)` + - `(*prompt.Document).CursorPositionCol` + - from `func (*prompt.Document).CursorPositionCol() (col strings.RuneNumber)` + - to `func (*prompt.Document).CursorPositionCol() (col strings.Width)` + - `(*prompt.Document).GetCursorRightPosition` + - from `func (*prompt.Document).GetCursorRightPosition(count strings.RuneNumber) strings.RuneNumber` + - to `func (*prompt.Document).GetCursorRightPosition(count strings.Width) strings.RuneNumber` + - `(*prompt.Document).GetCursorLeftPosition` + - from `func (*prompt.Document).GetCursorLeftPosition(count strings.RuneNumber) strings.RuneNumber` + - to `func (*prompt.Document).GetCursorLeftPosition(count strings.Width) strings.RuneNumber` + - `(*prompt.Document).GetCursorUpPosition` + - from `func (*prompt.Document).GetCursorUpPosition(count int, preferredColumn strings.RuneNumber) strings.RuneNumber` + - to `func (*prompt.Document).GetCursorUpPosition(count int, preferredColumn strings.Width) strings.RuneNumber` + - `(*prompt.Document).GetCursorDownPosition` + - from `func (*prompt.Document).GetCursorDownPosition(count int, preferredColumn strings.RuneNumber) strings.RuneNumber` + - to `func (*prompt.Document).GetCursorDownPosition(count int, preferredColumn strings.Width) strings.RuneNumber` + - `(*prompt.Document).TranslateRowColToIndex` + - from `func (*prompt.Document).TranslateRowColToIndex(row int, column strings.RuneNumber) strings.RuneNumber` + - to `func (*prompt.Document).TranslateRowColToIndex(row int, column strings.Width) strings.RuneNumber` + - `(*prompt.Buffer).Delete` + - from `func (*Buffer).Delete(count istrings.RuneNumber, col istrings.Width, row int) string` + - to `func (*Buffer).Delete(count istrings.GraphemeNumber, col istrings.Width, row int) string` + - `(*prompt.Buffer).DeleteBeforeCursor` + - from `func (*Buffer).DeleteBeforeCursor(count istrings.RuneNumber, col istrings.Width, row int) string` + - to `func (*Buffer).DeleteBeforeCursor(count istrings.GraphemeNumber, col istrings.Width, row int) string` + ## [1.0.3] - 25.07.2023 [Diff](https://github.com/elk-language/go-prompt/compare/v1.0.2...elk-language:go-prompt:v1.0.3) diff --git a/_example/automatic-indenter/main.go b/_example/automatic-indenter/main.go index f14f4646..dd333eea 100644 --- a/_example/automatic-indenter/main.go +++ b/_example/automatic-indenter/main.go @@ -18,7 +18,8 @@ func main() { p.Run() } -func ExecuteOnEnter(input string, indentSize int) (int, bool) { +func ExecuteOnEnter(buffer *prompt.Buffer, indentSize int) (int, bool) { + input := buffer.Text() lines := strings.SplitAfter(input, "\n") var spaces int if len(lines) > 0 { diff --git a/_example/bang-executor/main.go b/_example/bang-executor/main.go index df4caf6f..04323325 100644 --- a/_example/bang-executor/main.go +++ b/_example/bang-executor/main.go @@ -17,7 +17,8 @@ func main() { p.Run() } -func ExecuteOnEnter(input string, indentSize int) (int, bool) { +func ExecuteOnEnter(buffer *prompt.Buffer, indentSize int) (int, bool) { + input := buffer.Text() char, _ := utf8.DecodeLastRuneInString(input) return 0, char == '!' } diff --git a/_example/simple-echo/cjk-cyrillic/main.go b/_example/simple-echo/cjk-cyrillic/main.go index 27f65248..24b2c0a6 100644 --- a/_example/simple-echo/cjk-cyrillic/main.go +++ b/_example/simple-echo/cjk-cyrillic/main.go @@ -4,20 +4,25 @@ import ( "fmt" prompt "github.com/elk-language/go-prompt" + pstrings "github.com/elk-language/go-prompt/strings" ) func executor(in string) { fmt.Println("Your input: " + in) } -func completer(in prompt.Document) []prompt.Suggest { +func completer(in prompt.Document) ([]prompt.Suggest, pstrings.RuneNumber, pstrings.RuneNumber) { s := []prompt.Suggest{ {Text: "こんにちは", Description: "'こんにちは' means 'Hello' in Japanese"}, {Text: "감사합니다", Description: "'안녕하세요' means 'Hello' in Korean."}, {Text: "您好", Description: "'您好' means 'Hello' in Chinese."}, {Text: "Добрый день", Description: "'Добрый день' means 'Hello' in Russian."}, } - return prompt.FilterHasPrefix(s, in.GetWordBeforeCursor(), true) + endIndex := in.CurrentRuneIndex() + w := in.GetWordBeforeCursor() + startIndex := endIndex - pstrings.RuneCountInString(w) + + return prompt.FilterHasPrefix(s, w, true), startIndex, endIndex } func main() { diff --git a/_example/simple-echo/main.go b/_example/simple-echo/main.go index 62be416e..0ece86c1 100644 --- a/_example/simple-echo/main.go +++ b/_example/simple-echo/main.go @@ -4,16 +4,21 @@ import ( "fmt" prompt "github.com/elk-language/go-prompt" + pstrings "github.com/elk-language/go-prompt/strings" ) -func completer(in prompt.Document) []prompt.Suggest { +func completer(in prompt.Document) ([]prompt.Suggest, pstrings.RuneNumber, pstrings.RuneNumber) { s := []prompt.Suggest{ {Text: "users", Description: "Store the username and age"}, {Text: "articles", Description: "Store the article text posted by user"}, {Text: "comments", Description: "Store the text commented to articles"}, {Text: "groups", Description: "Combine users with specific rules"}, } - return prompt.FilterHasPrefix(s, in.GetWordBeforeCursor(), true) + endIndex := in.CurrentRuneIndex() + w := in.GetWordBeforeCursor() + startIndex := endIndex - pstrings.RuneCountInString(w) + + return prompt.FilterHasPrefix(s, w, true), startIndex, endIndex } func main() { @@ -22,7 +27,6 @@ func main() { prompt.WithTitle("sql-prompt"), prompt.WithHistory([]string{"SELECT * FROM users;"}), prompt.WithPrefixTextColor(prompt.Yellow), - prompt.WithPreviewSuggestionTextColor(prompt.Blue), prompt.WithSelectedSuggestionBGColor(prompt.LightGray), prompt.WithSuggestionBGColor(prompt.DarkGray), prompt.WithCompleter(completer), diff --git a/buffer.go b/buffer.go index d0fc6682..85b4adde 100644 --- a/buffer.go +++ b/buffer.go @@ -5,6 +5,7 @@ import ( "github.com/elk-language/go-prompt/debug" istrings "github.com/elk-language/go-prompt/strings" + "golang.org/x/exp/utf8string" ) // Buffer emulates the console buffer. @@ -14,7 +15,7 @@ type Buffer struct { startLine int // Line number of the first visible line in the terminal (0-indexed) cursorPosition istrings.RuneNumber cacheDocument *Document - preferredColumn istrings.RuneNumber // Remember the original column for the next up/down movement. + preferredColumn istrings.Width // Remember the original column for the next up/down movement. lastKeyStroke Key } @@ -187,22 +188,59 @@ func (b *Buffer) CursorDown(count int, columns istrings.Width, rows int) bool { return b.recalculateStartLine(columns, rows) } -// DeleteBeforeCursor delete specified number of characters before cursor and return the deleted text. -func (b *Buffer) DeleteBeforeCursor(count istrings.RuneNumber, columns istrings.Width, rows int) (deleted string) { +// DeleteBeforeCursor delete specified number of graphemes before the cursor and returns the deleted text. +func (b *Buffer) DeleteBeforeCursor(count istrings.GraphemeNumber, columns istrings.Width, rows int) string { debug.Assert(count >= 0, "count should be positive") + if b.cursorPosition < 0 { + return "" + } + + var deleted string + + textUtf8 := utf8string.NewString(b.Text()) + textBeforeCursor := textUtf8.Slice(0, int(b.cursorPosition)) + graphemeLength := istrings.GraphemeCount(textBeforeCursor) + + start := istrings.RuneIndexNthGrapheme(textBeforeCursor, graphemeLength-count) + if start < 0 { + start = 0 + } + deleted = textUtf8.Slice(int(start), int(b.cursorPosition)) + b.setDocument( + &Document{ + Text: textUtf8.Slice(0, int(start)) + textUtf8.Slice(int(b.cursorPosition), textUtf8.RuneCount()), + cursorPosition: b.cursorPosition - istrings.RuneCountInString(deleted), + }, + columns, + rows, + ) + + b.recalculateStartLine(columns, rows) + b.updatePreferredColumn() + return deleted +} + +// Deletes the specified number of runes before the cursor and returns the deleted text. +func (b *Buffer) DeleteBeforeCursorRunes(count istrings.RuneNumber, columns istrings.Width, rows int) (deleted string) { + debug.Assert(count >= 0, "count should be positive") + if b.cursorPosition <= 0 { + return "" + } r := []rune(b.Text()) - if b.cursorPosition > 0 { - start := b.cursorPosition - count - if start < 0 { - start = 0 - } - deleted = string(r[start:b.cursorPosition]) - b.setDocument(&Document{ + start := b.cursorPosition - count + if start < 0 { + start = 0 + } + deleted = string(r[start:b.cursorPosition]) + b.setDocument( + &Document{ Text: string(r[:start]) + string(r[b.cursorPosition:]), cursorPosition: b.cursorPosition - istrings.RuneNumber(len([]rune(deleted))), - }, columns, rows) - } + }, + columns, + rows, + ) b.recalculateStartLine(columns, rows) b.updatePreferredColumn() return @@ -217,8 +255,30 @@ func (b *Buffer) NewLine(columns istrings.Width, rows int, copyMargin bool) { } } -// Delete specified number of characters and Return the deleted text. -func (b *Buffer) Delete(count istrings.RuneNumber, col istrings.Width, row int) string { +// Deletes the specified number of graphemes and returns the deleted text. +func (b *Buffer) Delete(count istrings.GraphemeNumber, col istrings.Width, row int) string { + textUtf8 := utf8string.NewString(b.Text()) + if b.cursorPosition >= istrings.RuneCountInString(b.Text()) { + return "" + } + + textAfterCursor := b.Document().TextAfterCursor() + textAfterCursorUtf8 := utf8string.NewString(textAfterCursor) + + deletedRunes := textAfterCursorUtf8.Slice(0, int(istrings.RuneIndexNthGrapheme(textAfterCursor, count))) + + b.setText( + textUtf8.Slice(0, int(b.cursorPosition))+textUtf8.Slice(int(b.cursorPosition)+int(istrings.RuneCountInString(deletedRunes)), textUtf8.RuneCount()), + col, + row, + ) + + deleted := string(deletedRunes) + return deleted +} + +// Deletes the specified number of runes and returns the deleted text. +func (b *Buffer) DeleteRunes(count istrings.RuneNumber, col istrings.Width, row int) string { r := []rune(b.Text()) if b.cursorPosition < istrings.RuneNumber(len(r)) { textAfterCursor := b.Document().TextAfterCursor() diff --git a/constructor.go b/constructor.go index f6d32c6d..8b44f3af 100644 --- a/constructor.go +++ b/constructor.go @@ -286,7 +286,7 @@ func WithExitChecker(fn ExitChecker) Option { } } -func DefaultExecuteOnEnterCallback(input string, indentSize int) (int, bool) { +func DefaultExecuteOnEnterCallback(buffer *Buffer, indentSize int) (int, bool) { return 0, true } diff --git a/document.go b/document.go index fad9b06b..50f11f79 100644 --- a/document.go +++ b/document.go @@ -62,6 +62,32 @@ func (d *Document) CurrentRuneIndex() istrings.RuneNumber { return d.cursorPosition } +// Returns the amount of spaces that the last line of input +// is indented with. +func (d *Document) LastLineIndentSpaces() int { + input := d.Text + lastNewline := strings.LastIndexByte(input, '\n') + var spaces int + for i := lastNewline + 1; i < len(input); i++ { + b := input[i] + if b != ' ' { + break + } + + spaces++ + } + + return spaces +} + +// Returns the indentation level of the last line of input. +func (d *Document) LastLineIndentLevel(indentSize int) int { + if indentSize == 0 { + return 0 + } + return d.LastLineIndentSpaces() / indentSize +} + // TextBeforeCursor returns the text before the cursor. func (d *Document) TextBeforeCursor() string { r := []rune(d.Text) @@ -354,10 +380,11 @@ func (d *Document) TextEndPositionRow() (row istrings.RuneNumber) { } // CursorPositionCol returns the current column. (0-based.) -func (d *Document) CursorPositionCol() (col istrings.RuneNumber) { - _, index := d.findLineStartIndex(d.cursorPosition) - col = d.cursorPosition - index - return +func (d *Document) CursorPositionCol() (col istrings.Width) { + _, lineStartIndex := d.findLineStartIndex(d.cursorPosition) + + text := utf8string.NewString(d.Text).Slice(int(lineStartIndex), int(d.cursorPosition)) + return istrings.GetWidth(text) } // Returns the amount of runes that the cursors should be moved by. @@ -420,18 +447,7 @@ func (d *Document) GetCursorRightPosition(count istrings.GraphemeNumber) istring 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 + return istrings.RuneIndexNthGrapheme(text, count) } // Returns the amount of runes that the cursors should be moved by. @@ -466,8 +482,8 @@ func (d *Document) GetEndOfTextPosition(columns istrings.Width) Position { // GetCursorUpPosition return the relative cursor position (character index) where we would be // if the user pressed the arrow-up button. -func (d *Document) GetCursorUpPosition(count int, preferredColumn istrings.RuneNumber) istrings.RuneNumber { - var col istrings.RuneNumber +func (d *Document) GetCursorUpPosition(count int, preferredColumn istrings.Width) istrings.RuneNumber { + var col istrings.Width if preferredColumn == -1 { // -1 means nil col = d.CursorPositionCol() } else { @@ -483,8 +499,8 @@ func (d *Document) GetCursorUpPosition(count int, preferredColumn istrings.RuneN // GetCursorDownPosition return the relative cursor position (character index) where we would be if the // user pressed the arrow-down button. -func (d *Document) GetCursorDownPosition(count int, preferredColumn istrings.RuneNumber) istrings.RuneNumber { - var col istrings.RuneNumber +func (d *Document) GetCursorDownPosition(count int, preferredColumn istrings.Width) istrings.RuneNumber { + var col istrings.Width if preferredColumn == -1 { // -1 means nil col = d.CursorPositionCol() } else { @@ -516,7 +532,7 @@ func (d *Document) TranslateIndexToPosition(index istrings.RuneNumber) (int, int // TranslateRowColToIndex given a (row, col), return the corresponding index. // (Row and col params are 0-based.) -func (d *Document) TranslateRowColToIndex(row int, column istrings.RuneNumber) (index istrings.RuneNumber) { +func (d *Document) TranslateRowColToIndex(row int, column istrings.Width) (index istrings.RuneNumber) { indices := d.lineStartIndices() if row < 0 { row = 0 @@ -524,23 +540,16 @@ func (d *Document) TranslateRowColToIndex(row int, column istrings.RuneNumber) ( row = len(indices) - 1 } index = indices[row] - line := []rune(d.Lines()[row]) - - // python) result += max(0, min(col, len(line))) - if column > 0 || len(line) > 0 { - if column > istrings.RuneNumber(len(line)) { - index += istrings.RuneNumber(len(line)) - } else { - index += istrings.RuneNumber(column) - } - } + line := d.Lines()[row] + + index += istrings.RuneIndexNthColumn(line, column) - text := []rune(d.Text) + runeLength := istrings.RuneCountInString(d.Text) // Keep in range. (len(self.text) is included, because the cursor can be // right after the end of the text as well.) // python) result = max(0, min(result, len(self.text))) - if index > istrings.RuneNumber(len(text)) { - index = istrings.RuneNumber(len(text)) + if index > runeLength { + index = runeLength } if index < 0 { index = 0 diff --git a/document_test.go b/document_test.go index 9c3c2db5..db890260 100644 --- a/document_test.go +++ b/document_test.go @@ -333,6 +333,50 @@ func TestDocument_TextBeforeCursor(t *testing.T) { } } +func TestDocument_LastLineIndentLevel(t *testing.T) { + tests := []struct { + document *Document + indentSize int + want int + }{ + { + document: &Document{ + Text: "line 1\nline 2\nline 3\n line 4", + }, + indentSize: 2, + want: 3, + }, + { + document: &Document{ + Text: "line 1\nline 2\nline 3\n line 4", + }, + indentSize: 1, + want: 6, + }, + { + document: &Document{ + Text: "line 1\nline 2\nline 3\n line 4", + }, + indentSize: 4, + want: 1, + }, + { + document: &Document{ + Text: "line 1\nline 2\nline 3\n line 4", + }, + indentSize: 0, + want: 0, + }, + } + + for i, tc := range tests { + got := tc.document.LastLineIndentLevel(tc.indentSize) + if got != tc.want { + t.Errorf("[%d] Should be %#v, got %#v", i, tc.want, got) + } + } +} + func TestDocument_TextAfterCursor(t *testing.T) { pattern := []struct { document *Document @@ -1071,7 +1115,7 @@ func TestDocument_CursorPositionRowAndCol(t *testing.T) { var cursorPositionTests = []struct { document *Document expectedRow istrings.RuneNumber - expectedCol istrings.RuneNumber + expectedCol istrings.Width }{ { document: &Document{Text: "line 1\nline 2\nline 3\n", cursorPosition: istrings.RuneCountInString("line 1\n" + "lin")}, @@ -1085,13 +1129,13 @@ func TestDocument_CursorPositionRowAndCol(t *testing.T) { }, } for _, test := range cursorPositionTests { - ac := test.document.CursorPositionRow() - if ac != test.expectedRow { - t.Errorf("Should be %#v, got %#v", test.expectedRow, ac) + r := test.document.CursorPositionRow() + if r != test.expectedRow { + t.Errorf("Should be %#v, got %#v", test.expectedRow, r) } - ac = test.document.CursorPositionCol() - if ac != test.expectedCol { - t.Errorf("Should be %#v, got %#v", test.expectedCol, ac) + c := test.document.CursorPositionCol() + if c != test.expectedCol { + t.Errorf("Should be %#v, got %#v", test.expectedCol, c) } } } diff --git a/emacs.go b/emacs.go index 223646f4..79ea4f43 100644 --- a/emacs.go +++ b/emacs.go @@ -64,7 +64,7 @@ var emacsKeyBindings = []KeyBind{ { Key: ControlK, Fn: func(p *Prompt) bool { - p.Buffer.Delete( + p.Buffer.DeleteRunes( istrings.RuneCountInString(p.Buffer.Document().CurrentLineAfterCursor()), p.renderer.col, p.renderer.row, @@ -76,7 +76,7 @@ var emacsKeyBindings = []KeyBind{ { Key: ControlU, Fn: func(p *Prompt) bool { - p.Buffer.DeleteBeforeCursor( + p.Buffer.DeleteBeforeCursorRunes( istrings.RuneCountInString(p.Buffer.Document().CurrentLineBeforeCursor()), p.renderer.col, p.renderer.row, diff --git a/key_bind_func.go b/key_bind_func.go index 59d4b499..7b997a14 100644 --- a/key_bind_func.go +++ b/key_bind_func.go @@ -39,7 +39,7 @@ func GoLeftChar(p *Prompt) bool { } func DeleteWordBeforeCursor(p *Prompt) bool { - p.Buffer.DeleteBeforeCursor( + p.Buffer.DeleteBeforeCursorRunes( istrings.RuneCountInString(p.Buffer.Document().GetWordBeforeCursorWithSpace()), p.renderer.col, p.renderer.row, diff --git a/prompt.go b/prompt.go index 89b47e0a..5838b3ea 100644 --- a/prompt.go +++ b/prompt.go @@ -33,7 +33,7 @@ type ExitChecker func(in string, breakline bool) bool // If this function returns true, the Executor callback will be called // otherwise a newline will be added to the buffer containing user input // and optionally indentation made up of `indentSize * indent` spaces. -type ExecuteOnEnterCallback func(input string, indentSize int) (indent int, execute bool) +type ExecuteOnEnterCallback func(buffer *Buffer, indentSize int) (indent int, execute bool) // Completer is a function that returns // a slice of suggestions for the given Document. @@ -149,6 +149,11 @@ func Log(format string, a ...any) { fmt.Fprintf(f, format+"\n", a...) } +// Returns the configured indent size. +func (p *Prompt) IndentSize() int { + return p.renderer.indentSize +} + func (p *Prompt) feed(b []byte) (shouldExit bool, rerender bool, userInput *UserInput) { key := GetKey(b) p.Buffer.lastKeyStroke = key @@ -163,7 +168,7 @@ func (p *Prompt) feed(b []byte) (shouldExit bool, rerender bool, userInput *User switch key { case Enter, ControlJ, ControlM: - indent, execute := p.executeOnEnterCallback(p.Buffer.Text(), p.renderer.indentSize) + indent, execute := p.executeOnEnterCallback(p.Buffer, p.renderer.indentSize) if !execute { p.Buffer.NewLine(cols, rows, false) @@ -306,13 +311,13 @@ keySwitch: break keySwitch } } - p.Buffer.DeleteBeforeCursor(istrings.RuneNumber(p.renderer.indentSize), cols, rows) + p.Buffer.DeleteBeforeCursorRunes(istrings.RuneNumber(p.renderer.indentSize), cols, rows) return true default: if s, ok := p.completion.GetSelectedSuggestion(); ok { w := p.Buffer.Document().GetWordBeforeCursorUntilSeparator(p.completion.wordSeparator) if w != "" { - p.Buffer.DeleteBeforeCursor(istrings.RuneNumber(len([]rune(w))), cols, rows) + p.Buffer.DeleteBeforeCursorRunes(istrings.RuneCountInString(w), cols, rows) } p.Buffer.InsertTextMoveCursor(s.Text, cols, rows, false) } @@ -344,13 +349,13 @@ func (p *Prompt) updateSuggestions(fn func()) { // insert the new selection if !prevSelected { - p.Buffer.DeleteBeforeCursor(p.completion.endCharIndex-p.completion.startCharIndex, cols, rows) + p.Buffer.DeleteBeforeCursorRunes(p.completion.endCharIndex-p.completion.startCharIndex, cols, rows) p.Buffer.InsertTextMoveCursor(newSuggestion.Text, cols, rows, false) return } // delete the previous selection if !newSelected { - p.Buffer.DeleteBeforeCursor( + p.Buffer.DeleteBeforeCursorRunes( istrings.RuneCountInString(prevSuggestion.Text)-(prevEnd-prevStart), cols, rows, @@ -359,7 +364,7 @@ func (p *Prompt) updateSuggestions(fn func()) { } // delete previous selection and render the new one - p.Buffer.DeleteBeforeCursor( + p.Buffer.DeleteBeforeCursorRunes( istrings.RuneCountInString(prevSuggestion.Text), cols, rows, diff --git a/strings/strings.go b/strings/strings.go index 0781b02a..64bccf0d 100644 --- a/strings/strings.go +++ b/strings/strings.go @@ -41,6 +41,45 @@ func GetRuneWidth(char rune) Width { return Width(runewidth.RuneWidth(char)) } +// Returns the rune index of the nth grapheme in the given text. +func RuneIndexNthGrapheme(text string, n GraphemeNumber) RuneNumber { + g := uniseg.NewGraphemes(text) + var currentGraphemeIndex GraphemeNumber + var currentPosition RuneNumber + + for g.Next() { + if currentGraphemeIndex >= n { + break + } + + currentPosition += RuneNumber(len(g.Runes())) + currentGraphemeIndex++ + } + return currentPosition +} + +// Returns the rune index of the nth column (in terms of char width) in the given text. +func RuneIndexNthColumn(text string, n Width) RuneNumber { + g := uniseg.NewGraphemes(text) + var currentColumnIndex Width + var currentPosition RuneNumber + var previousPosition RuneNumber + + for g.Next() { + if currentColumnIndex > n { + currentPosition = previousPosition + break + } + if currentColumnIndex == n { + break + } + previousPosition = currentPosition + currentPosition += RuneNumber(len(g.Runes())) + currentColumnIndex += Width(g.Width()) + } + return currentPosition +} + // IndexNotByte is similar with strings.IndexByte but showing the opposite behavior. func IndexNotByte(s string, c byte) ByteNumber { n := len(s) diff --git a/strings/strings_test.go b/strings/strings_test.go index 849cb2fc..ea4dca3c 100644 --- a/strings/strings_test.go +++ b/strings/strings_test.go @@ -37,6 +37,86 @@ func TestGetWidth(t *testing.T) { } } +func TestRuneIndexNthColumn(t *testing.T) { + tests := []struct { + text string + n strings.Width + want strings.RuneNumber + }{ + { + text: "foo", + n: 2, + want: 2, + }, + { + text: "foo", + n: 10, + want: 3, + }, + { + text: "foo", + n: 0, + want: 0, + }, + { + text: "foo日本bar", + n: 7, + want: 5, + }, + { + text: "foo🇵🇱🙆🏿‍♂️bar", + n: 7, + want: 10, + }, + } + + for _, tc := range tests { + if got := strings.RuneIndexNthColumn(tc.text, tc.n); got != tc.want { + t.Errorf("Should be %#v, but got %#v, for %#v", tc.want, got, tc.text) + } + } +} + +func TestRuneIndexNthGrapheme(t *testing.T) { + tests := []struct { + text string + n strings.GraphemeNumber + want strings.RuneNumber + }{ + { + text: "foo", + n: 2, + want: 2, + }, + { + text: "foo", + n: 10, + want: 3, + }, + { + text: "foo", + n: 0, + want: 0, + }, + { + text: "foo日本bar", + n: 7, + want: 7, + }, + { + text: "foo🇵🇱🙆🏿‍♂️bar", + n: 7, + want: 12, + }, + } + + for _, tc := range tests { + if got := strings.RuneIndexNthGrapheme(tc.text, tc.n); got != tc.want { + t.Errorf("Should be %#v, but got %#v, for %#v", tc.want, got, tc.text) + } + } +} + func ExampleIndexNotByte() { fmt.Println(strings.IndexNotByte("golang", 'g')) fmt.Println(strings.IndexNotByte("golang", 'x')) From bf5172c17af940494e52ad309f5db3ea6804a6f3 Mon Sep 17 00:00:00 2001 From: Mateusz Drewniak Date: Fri, 28 Jul 2023 17:45:33 +0200 Subject: [PATCH 3/3] Rename `GraphemeCount` to `GraphemeCountInString` --- CHANGELOG.md | 5 +++++ buffer.go | 2 +- document.go | 2 +- strings/strings.go | 2 +- 4 files changed, 8 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ffb3f03..638de464 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Add `strings.GraphemeNumber`, a type that represents the amount of grapheme clusters in a string (or an offset of a grapheme cluster in a string) - `type strings.GraphemeNumber int` +- `func strings.GraphemeCountInString(text string) strings.GraphemeNumber` +- `func strings.RuneCountInString(s string) strings.RuneNumber` - `func strings.RuneIndexNthGrapheme(text string, n strings.GraphemeNumber) strings.RuneNumber` - `func strings.RuneIndexNthColumn(text string, n strings.Width) strings.RuneNumber` - `func (*prompt.Document).GetCursorLeftPositionRunes(count strings.RuneNumber) strings.RuneNumber` @@ -26,6 +28,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Change signatures: + - `strings.RuneCount` + - from `func strings.RuneCount(s string) strings.RuneNumber` + - to `func strings.RuneCount(b []byte) strings.RuneNumber` - `prompt.ExecuteOnEnterCallback` - from `func(input string, indentSize int) (indent int, execute bool)` - to `func(buffer *prompt.Buffer, indentSize int) (indent int, execute bool)` diff --git a/buffer.go b/buffer.go index 85b4adde..5ae48152 100644 --- a/buffer.go +++ b/buffer.go @@ -199,7 +199,7 @@ func (b *Buffer) DeleteBeforeCursor(count istrings.GraphemeNumber, columns istri textUtf8 := utf8string.NewString(b.Text()) textBeforeCursor := textUtf8.Slice(0, int(b.cursorPosition)) - graphemeLength := istrings.GraphemeCount(textBeforeCursor) + graphemeLength := istrings.GraphemeCountInString(textBeforeCursor) start := istrings.RuneIndexNthGrapheme(textBeforeCursor, graphemeLength-count) if start < 0 { diff --git a/document.go b/document.go index 50f11f79..9ca57f1e 100644 --- a/document.go +++ b/document.go @@ -399,7 +399,7 @@ func (d *Document) GetCursorLeftPosition(count istrings.GraphemeNumber) istrings } text := d.TextBeforeCursor() g := uniseg.NewGraphemes(text) - graphemeLength := istrings.GraphemeCount(text) + graphemeLength := istrings.GraphemeCountInString(text) var currentGraphemeIndex istrings.GraphemeNumber var currentPosition istrings.RuneNumber diff --git a/strings/strings.go b/strings/strings.go index 64bccf0d..24ee4f49 100644 --- a/strings/strings.go +++ b/strings/strings.go @@ -32,7 +32,7 @@ func GetWidth(text string) Width { // 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 { +func GraphemeCountInString(text string) GraphemeNumber { return GraphemeNumber(uniseg.GraphemeClusterCount(text)) }