Skip to content

Commit

Permalink
Fix vertical cursor movement
Browse files Browse the repository at this point in the history
  • Loading branch information
Verseth committed Jul 28, 2023
1 parent e5cbb07 commit 6723651
Show file tree
Hide file tree
Showing 14 changed files with 370 additions and 73 deletions.
49 changes: 49 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
3 changes: 2 additions & 1 deletion _example/automatic-indenter/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
3 changes: 2 additions & 1 deletion _example/bang-executor/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 == '!'
}
Expand Down
9 changes: 7 additions & 2 deletions _example/simple-echo/cjk-cyrillic/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
10 changes: 7 additions & 3 deletions _example/simple-echo/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -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),
Expand Down
88 changes: 74 additions & 14 deletions buffer.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
}

Expand Down Expand Up @@ -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
Expand All @@ -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()
Expand Down
2 changes: 1 addition & 1 deletion constructor.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
77 changes: 43 additions & 34 deletions document.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -516,31 +532,24 @@ 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
} else if row > len(indices) {
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
Expand Down
Loading

0 comments on commit 6723651

Please sign in to comment.