From 2270f410eb2519f7470a61074d49e7c7c240a30a Mon Sep 17 00:00:00 2001 From: Mateusz Drewniak Date: Sun, 16 Jul 2023 23:25:29 +0200 Subject: [PATCH 01/16] scrolling buffer with uncoloured text --- buffer.go | 102 ++++++++++++++++++++++++++++++++++++----------- buffer_test.go | 96 ++++++++++++++++++++++---------------------- constructor.go | 2 +- emacs.go | 66 ++++++++++++++++++------------ emacs_test.go | 4 +- history.go | 12 ++++-- history_test.go | 8 ++-- key_bind.go | 6 ++- key_bind_func.go | 24 +++++------ position.go | 63 ++++++++++++++++++++++++++++- prompt.go | 47 +++++++++++++--------- reader.go | 5 +++ reader_posix.go | 4 +- renderer.go | 58 ++++++++++++++++++--------- 14 files changed, 335 insertions(+), 162 deletions(-) diff --git a/buffer.go b/buffer.go index 0336bd45..102af5bb 100644 --- a/buffer.go +++ b/buffer.go @@ -11,6 +11,7 @@ import ( type Buffer struct { workingLines []string // The working lines. Similar to history workingIndex int // index of the current line + startLine int // Line number of the first visible line in the terminal (0-indexed) cursorPosition istrings.RuneNumber cacheDocument *Document lastKeyStroke Key @@ -41,8 +42,18 @@ func (b *Buffer) DisplayCursorPosition(columns istrings.Width) Position { return b.Document().DisplayCursorPosition(columns) } -// InsertText insert string from current line. -func (b *Buffer) InsertText(text string, overwrite bool, moveCursor bool) { +// Insert string into the buffer and move the cursor. +func (b *Buffer) InsertTextMoveCursor(text string, columns istrings.Width, rows int, overwrite bool) { + b.insertText(text, columns, rows, overwrite, true) +} + +// Insert string into the buffer without moving the cursor. +func (b *Buffer) InsertText(text string, overwrite bool) { + b.insertText(text, 0, 0, overwrite, false) +} + +// insertText insert string from current line. +func (b *Buffer) insertText(text string, columns istrings.Width, rows int, overwrite bool, moveCursor bool) { currentTextRunes := []rune(b.Text()) cursor := b.cursorPosition @@ -54,22 +65,49 @@ func (b *Buffer) InsertText(text string, overwrite bool, moveCursor bool) { if i := strings.IndexAny(overwritten, "\n"); i != -1 { overwritten = overwritten[:i] } - b.setText(string(currentTextRunes[:cursor]) + text + string(currentTextRunes[cursor+istrings.RuneCount(overwritten):])) + b.setText( + string(currentTextRunes[:cursor])+text+string(currentTextRunes[cursor+istrings.RuneCount(overwritten):]), + columns, + rows, + ) } else { - b.setText(string(currentTextRunes[:cursor]) + text + string(currentTextRunes[cursor:])) + b.setText( + string(currentTextRunes[:cursor])+text+string(currentTextRunes[cursor:]), + columns, + rows, + ) } if moveCursor { b.cursorPosition += istrings.RuneCount(text) + b.RecalculateStartLine(columns, rows) + } +} + +func (b *Buffer) ResetStartLine() { + b.startLine = 0 +} + +func (b *Buffer) RecalculateStartLine(columns istrings.Width, rows int) { + pos := b.DisplayCursorPosition(columns) + if pos.Y > b.startLine+rows-1 { + b.startLine = pos.Y - rows + 1 + } else if pos.Y < b.startLine { + b.startLine = pos.Y + } + + if b.startLine < 0 { + b.startLine = 0 } } // SetText method to set text and update cursorPosition. // (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) { +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") b.workingLines[b.workingIndex] = text + b.RecalculateStartLine(col, row) } // Set cursor position. Return whether it changed. @@ -81,40 +119,45 @@ func (b *Buffer) setCursorPosition(p istrings.RuneNumber) { } } -func (b *Buffer) setDocument(d *Document) { +func (b *Buffer) setDocument(d *Document, columns istrings.Width, rows int) { b.cacheDocument = d b.setCursorPosition(d.cursorPosition) // Call before setText because setText check the relation between cursorPosition and line length. - b.setText(d.Text) + b.setText(d.Text, columns, rows) + b.RecalculateStartLine(columns, rows) } // CursorLeft move to left on the current line. -func (b *Buffer) CursorLeft(count istrings.RuneNumber) { +func (b *Buffer) CursorLeft(count istrings.RuneNumber, columns istrings.Width, rows int) { l := b.Document().GetCursorLeftPosition(count) b.cursorPosition += l + b.RecalculateStartLine(columns, rows) } // CursorRight move to right on the current line. -func (b *Buffer) CursorRight(count istrings.RuneNumber) { +func (b *Buffer) CursorRight(count istrings.RuneNumber, columns istrings.Width, rows int) { l := b.Document().GetCursorRightPosition(count) b.cursorPosition += l + b.RecalculateStartLine(columns, rows) } // CursorUp move cursor to the previous line. // (for multi-line edit). -func (b *Buffer) CursorUp(count int) { +func (b *Buffer) CursorUp(count int, columns istrings.Width, rows int) { orig := b.Document().CursorPositionCol() b.cursorPosition += b.Document().GetCursorUpPosition(count, orig) + b.RecalculateStartLine(columns, rows) } // CursorDown move cursor to the next line. // (for multi-line edit). -func (b *Buffer) CursorDown(count int) { +func (b *Buffer) CursorDown(count int, columns istrings.Width, rows int) { orig := b.Document().CursorPositionCol() b.cursorPosition += b.Document().GetCursorDownPosition(count, orig) + b.RecalculateStartLine(columns, rows) } // DeleteBeforeCursor delete specified number of characters before cursor and return the deleted text. -func (b *Buffer) DeleteBeforeCursor(count istrings.RuneNumber) (deleted string) { +func (b *Buffer) DeleteBeforeCursor(count istrings.RuneNumber, columns istrings.Width, rows int) (deleted string) { debug.Assert(count >= 0, "count should be positive") r := []rune(b.Text()) @@ -127,28 +170,32 @@ func (b *Buffer) DeleteBeforeCursor(count istrings.RuneNumber) (deleted string) b.setDocument(&Document{ Text: string(r[:start]) + string(r[b.cursorPosition:]), cursorPosition: b.cursorPosition - istrings.RuneNumber(len([]rune(deleted))), - }) + }, columns, rows) } return } // NewLine means CR. -func (b *Buffer) NewLine(copyMargin bool) { +func (b *Buffer) NewLine(columns istrings.Width, rows int, copyMargin bool) { if copyMargin { - b.InsertText("\n"+b.Document().leadingWhitespaceInCurrentLine(), false, true) + b.InsertTextMoveCursor("\n"+b.Document().leadingWhitespaceInCurrentLine(), columns, rows, false) } else { - b.InsertText("\n", false, true) + b.InsertTextMoveCursor("\n", columns, rows, false) } } // Delete specified number of characters and Return the deleted text. -func (b *Buffer) Delete(count istrings.RuneNumber) string { +func (b *Buffer) Delete(count istrings.RuneNumber, col istrings.Width, row int) string { r := []rune(b.Text()) if b.cursorPosition < istrings.RuneNumber(len(r)) { textAfterCursor := b.Document().TextAfterCursor() textAfterCursorRunes := []rune(textAfterCursor) deletedRunes := textAfterCursorRunes[:count] - b.setText(string(r[:b.cursorPosition]) + string(r[b.cursorPosition+istrings.RuneNumber(len(deletedRunes)):])) + b.setText( + string(r[:b.cursorPosition])+string(r[b.cursorPosition+istrings.RuneNumber(len(deletedRunes)):]), + col, + row, + ) deleted := string(deletedRunes) return deleted @@ -158,21 +205,29 @@ func (b *Buffer) Delete(count istrings.RuneNumber) string { } // JoinNextLine joins the next line to the current one by deleting the line ending after the current line. -func (b *Buffer) JoinNextLine(separator string) { +func (b *Buffer) JoinNextLine(separator string, col istrings.Width, row int) { if !b.Document().OnLastLine() { b.cursorPosition += b.Document().GetEndOfLinePosition() - b.Delete(1) + b.Delete(1, col, row) // Remove spaces - b.setText(b.Document().TextBeforeCursor() + separator + strings.TrimLeft(b.Document().TextAfterCursor(), " ")) + b.setText( + b.Document().TextBeforeCursor()+separator+strings.TrimLeft(b.Document().TextAfterCursor(), " "), + col, + row, + ) } } // SwapCharactersBeforeCursor swaps the last two characters before the cursor. -func (b *Buffer) SwapCharactersBeforeCursor() { +func (b *Buffer) SwapCharactersBeforeCursor(col istrings.Width, row int) { if b.cursorPosition >= 2 { x := b.Text()[b.cursorPosition-2 : b.cursorPosition-1] y := b.Text()[b.cursorPosition-1 : b.cursorPosition] - b.setText(b.Text()[:b.cursorPosition-2] + y + x + b.Text()[b.cursorPosition:]) + b.setText( + b.Text()[:b.cursorPosition-2]+y+x+b.Text()[b.cursorPosition:], + col, + row, + ) } } @@ -181,6 +236,7 @@ func NewBuffer() (b *Buffer) { b = &Buffer{ workingLines: []string{""}, workingIndex: 0, + startLine: 0, } return } diff --git a/buffer_test.go b/buffer_test.go index 644a5be7..0db735a0 100644 --- a/buffer_test.go +++ b/buffer_test.go @@ -19,7 +19,7 @@ func TestNewBuffer(t *testing.T) { func TestBuffer_InsertText(t *testing.T) { b := NewBuffer() - b.InsertText("some_text", false, true) + b.InsertTextMoveCursor("some_text", DefColCount, DefRowCount, false) if b.Text() != "some_text" { t.Errorf("Text should be %#v, got %#v", "some_text", b.Text()) @@ -32,7 +32,7 @@ func TestBuffer_InsertText(t *testing.T) { func TestBuffer_InsertText_Overwrite(t *testing.T) { b := NewBuffer() - b.InsertText("ABC", false, true) + b.InsertTextMoveCursor("ABC", DefColCount, DefRowCount, false) if b.Text() != "ABC" { t.Errorf("Text should be %#v, got %#v", "ABC", b.Text()) @@ -42,34 +42,34 @@ func TestBuffer_InsertText_Overwrite(t *testing.T) { t.Errorf("cursorPosition should be %#v, got %#v", istrings.RuneCount("ABC"), b.cursorPosition) } - b.CursorLeft(1) + b.CursorLeft(1, DefColCount, DefRowCount) // Replace C with DEF in ABC - b.InsertText("DEF", true, true) + b.InsertTextMoveCursor("DEF", DefColCount, DefRowCount, true) if b.Text() != "ABDEF" { t.Errorf("Text should be %#v, got %#v", "ABDEF", b.Text()) } - b.CursorLeft(100) + b.CursorLeft(100, DefColCount, DefRowCount) // Replace ABD with GHI in ABDEF - b.InsertText("GHI", true, true) + b.InsertTextMoveCursor("GHI", DefColCount, DefRowCount, true) if b.Text() != "GHIEF" { t.Errorf("Text should be %#v, got %#v", "GHIEF", b.Text()) } - b.CursorLeft(100) + b.CursorLeft(100, DefColCount, DefRowCount) // Replace GHI with J\nK in GHIEF - b.InsertText("J\nK", true, true) + b.InsertTextMoveCursor("J\nK", DefColCount, DefRowCount, true) if b.Text() != "J\nKEF" { t.Errorf("Text should be %#v, got %#v", "J\nKEF", b.Text()) } - b.CursorUp(100) - b.CursorLeft(100) + b.CursorUp(100, DefColCount, DefRowCount) + b.CursorLeft(100, DefColCount, DefRowCount) // Replace J with LMN in J\nKEF test end of line - b.InsertText("LMN", true, true) + b.InsertTextMoveCursor("LMN", DefColCount, DefRowCount, true) if b.Text() != "LMN\nKEF" { t.Errorf("Text should be %#v, got %#v", "LMN\nKEF", b.Text()) @@ -78,12 +78,12 @@ func TestBuffer_InsertText_Overwrite(t *testing.T) { func TestBuffer_CursorMovement(t *testing.T) { b := NewBuffer() - b.InsertText("some_text", false, true) + b.InsertTextMoveCursor("some_text", DefColCount, DefRowCount, false) - b.CursorLeft(1) - b.CursorLeft(2) - b.CursorRight(1) - b.InsertText("A", false, true) + b.CursorLeft(1, DefColCount, DefRowCount) + b.CursorLeft(2, DefColCount, DefRowCount) + b.CursorRight(1, DefColCount, DefRowCount) + b.InsertTextMoveCursor("A", DefColCount, DefRowCount, false) if b.Text() != "some_teAxt" { t.Errorf("Text should be %#v, got %#v", "some_teAxt", b.Text()) } @@ -92,8 +92,8 @@ func TestBuffer_CursorMovement(t *testing.T) { } // Moving over left character counts. - b.CursorLeft(100) - b.InsertText("A", false, true) + b.CursorLeft(100, DefColCount, DefRowCount) + b.InsertTextMoveCursor("A", DefColCount, DefRowCount, false) if b.Text() != "Asome_teAxt" { t.Errorf("Text should be %#v, got %#v", "some_teAxt", b.Text()) } @@ -106,12 +106,12 @@ func TestBuffer_CursorMovement(t *testing.T) { func TestBuffer_CursorMovement_WithMultiByte(t *testing.T) { b := NewBuffer() - b.InsertText("あいうえお", false, true) - b.CursorLeft(1) + b.InsertTextMoveCursor("あいうえお", DefColCount, DefRowCount, false) + b.CursorLeft(1, DefColCount, DefRowCount) if l := b.Document().TextAfterCursor(); l != "お" { t.Errorf("Should be 'お', but got %s", l) } - b.InsertText("żółć", true, true) + b.InsertTextMoveCursor("żółć", DefColCount, DefRowCount, true) if b.Text() != "あいうえżółć" { t.Errorf("Text should be %#v, got %#v", "あいうえżółć", b.Text()) } @@ -119,22 +119,22 @@ func TestBuffer_CursorMovement_WithMultiByte(t *testing.T) { func TestBuffer_CursorUp(t *testing.T) { b := NewBuffer() - b.InsertText("long line1\nline2", false, true) - b.CursorUp(1) + b.InsertTextMoveCursor("long line1\nline2", DefColCount, DefRowCount, false) + b.CursorUp(1, DefColCount, DefRowCount) if b.Document().cursorPosition != 5 { t.Errorf("Should be %#v, got %#v", 5, b.Document().cursorPosition) } // Going up when already at the top. - b.CursorUp(1) + b.CursorUp(1, DefColCount, DefRowCount) if b.Document().cursorPosition != 5 { t.Errorf("Should be %#v, got %#v", 5, b.Document().cursorPosition) } // Going up to a line that's shorter. - b.setDocument(&Document{}) - b.InsertText("line1\nlong line2", false, true) - b.CursorUp(1) + b.setDocument(&Document{}, DefColCount, DefRowCount) + b.InsertTextMoveCursor("line1\nlong line2", DefColCount, DefRowCount, false) + b.CursorUp(1, DefColCount, DefRowCount) if b.Document().cursorPosition != 5 { t.Errorf("Should be %#v, got %#v", 5, b.Document().cursorPosition) } @@ -142,20 +142,20 @@ func TestBuffer_CursorUp(t *testing.T) { func TestBuffer_CursorDown(t *testing.T) { b := NewBuffer() - b.InsertText("line1\nline2", false, true) + b.InsertTextMoveCursor("line1\nline2", DefColCount, DefRowCount, false) b.cursorPosition = 3 // Normally going down - b.CursorDown(1) + 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) } // Going down to a line that's storter. b = NewBuffer() - b.InsertText("long line1\na\nb", false, true) + b.InsertTextMoveCursor("long line1\na\nb", DefColCount, DefRowCount, false) b.cursorPosition = 3 - b.CursorDown(1) + 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) } @@ -163,9 +163,9 @@ func TestBuffer_CursorDown(t *testing.T) { func TestBuffer_DeleteBeforeCursor(t *testing.T) { b := NewBuffer() - b.InsertText("some_text", false, true) - b.CursorLeft(2) - deleted := b.DeleteBeforeCursor(1) + b.InsertTextMoveCursor("some_text", DefColCount, DefRowCount, false) + b.CursorLeft(2, DefColCount, DefRowCount) + deleted := b.DeleteBeforeCursor(1, DefColCount, DefRowCount) if b.Text() != "some_txt" { t.Errorf("Should be %#v, got %#v", "some_txt", b.Text()) @@ -178,7 +178,7 @@ func TestBuffer_DeleteBeforeCursor(t *testing.T) { } // Delete over the characters length before cursor. - deleted = b.DeleteBeforeCursor(100) + deleted = b.DeleteBeforeCursor(100, DefColCount, DefRowCount) if deleted != "some_t" { t.Errorf("Should be %#v, got %#v", "some_t", deleted) } @@ -187,7 +187,7 @@ func TestBuffer_DeleteBeforeCursor(t *testing.T) { } // If cursor position is a beginning of line, it has no effect. - deleted = b.DeleteBeforeCursor(1) + deleted = b.DeleteBeforeCursor(1, DefColCount, DefRowCount) if deleted != "" { t.Errorf("Should be empty, got %#v", deleted) } @@ -195,8 +195,8 @@ func TestBuffer_DeleteBeforeCursor(t *testing.T) { func TestBuffer_NewLine(t *testing.T) { b := NewBuffer() - b.InsertText(" hello", false, true) - b.NewLine(false) + b.InsertTextMoveCursor(" hello", DefColCount, DefRowCount, false) + b.NewLine(DefColCount, DefRowCount, false) ac := b.Text() ex := " hello\n" if ac != ex { @@ -204,8 +204,8 @@ func TestBuffer_NewLine(t *testing.T) { } b = NewBuffer() - b.InsertText(" hello", false, true) - b.NewLine(true) + b.InsertTextMoveCursor(" hello", DefColCount, DefRowCount, false) + b.NewLine(DefColCount, DefRowCount, true) ac = b.Text() ex = " hello\n " if ac != ex { @@ -215,9 +215,9 @@ func TestBuffer_NewLine(t *testing.T) { func TestBuffer_JoinNextLine(t *testing.T) { b := NewBuffer() - b.InsertText("line1\nline2\nline3", false, true) - b.CursorUp(1) - b.JoinNextLine(" ") + b.InsertTextMoveCursor("line1\nline2\nline3", DefColCount, DefRowCount, false) + b.CursorUp(1, DefColCount, DefRowCount) + b.JoinNextLine(" ", DefColCount, DefRowCount) ac := b.Text() ex := "line1\nline2 line3" @@ -227,9 +227,9 @@ func TestBuffer_JoinNextLine(t *testing.T) { // Test when there is no '\n' in the text b = NewBuffer() - b.InsertText("line1", false, true) + b.InsertTextMoveCursor("line1", DefColCount, DefRowCount, false) b.cursorPosition = 0 - b.JoinNextLine(" ") + b.JoinNextLine(" ", DefColCount, DefRowCount) ac = b.Text() ex = "line1" if ac != ex { @@ -239,9 +239,9 @@ func TestBuffer_JoinNextLine(t *testing.T) { func TestBuffer_SwapCharactersBeforeCursor(t *testing.T) { b := NewBuffer() - b.InsertText("hello world", false, true) - b.CursorLeft(2) - b.SwapCharactersBeforeCursor() + b.InsertTextMoveCursor("hello world", DefColCount, DefRowCount, false) + b.CursorLeft(2, DefColCount, DefRowCount) + b.SwapCharactersBeforeCursor(DefColCount, DefRowCount) ac := b.Text() ex := "hello wrold" if ac != ex { diff --git a/constructor.go b/constructor.go index 843f524c..cb66d643 100644 --- a/constructor.go +++ b/constructor.go @@ -80,7 +80,7 @@ func WithPrefix(prefix string) Option { // WithInitialText can be used to set the initial buffer text. func WithInitialText(text string) Option { return func(p *Prompt) error { - p.buf.InsertText(text, false, true) + p.buf.InsertTextMoveCursor(text, p.renderer.col, int(p.renderer.row), true) return nil } } diff --git a/emacs.go b/emacs.go index 4770a360..a7f4550f 100644 --- a/emacs.go +++ b/emacs.go @@ -45,92 +45,108 @@ var emacsKeyBindings = []KeyBind{ // Go to the End of the line { Key: ControlE, - Fn: func(buf *Buffer) { - buf.CursorRight(istrings.RuneCount(buf.Document().CurrentLineAfterCursor())) + Fn: func(buf *Buffer, cols istrings.Width, rows int) { + buf.CursorRight(istrings.RuneCount(buf.Document().CurrentLineAfterCursor()), cols, rows) }, }, // Go to the beginning of the line { Key: ControlA, - Fn: func(buf *Buffer) { - buf.CursorLeft(buf.Document().FindStartOfFirstWordOfLine()) + Fn: func(buf *Buffer, cols istrings.Width, rows int) { + buf.CursorLeft(buf.Document().FindStartOfFirstWordOfLine(), cols, rows) }, }, // Cut the Line after the cursor { Key: ControlK, - Fn: func(buf *Buffer) { - buf.Delete(istrings.RuneCount(buf.Document().CurrentLineAfterCursor())) + Fn: func(buf *Buffer, cols istrings.Width, rows int) { + buf.Delete(istrings.RuneCount(buf.Document().CurrentLineAfterCursor()), cols, rows) }, }, // Cut/delete the Line before the cursor { Key: ControlU, - Fn: func(buf *Buffer) { - buf.DeleteBeforeCursor(istrings.RuneCount(buf.Document().CurrentLineBeforeCursor())) + Fn: func(buf *Buffer, cols istrings.Width, rows int) { + buf.DeleteBeforeCursor(istrings.RuneCount(buf.Document().CurrentLineBeforeCursor()), cols, rows) }, }, // Delete character under the cursor { Key: ControlD, - Fn: func(buf *Buffer) { + Fn: func(buf *Buffer, cols istrings.Width, rows int) { if buf.Text() != "" { - buf.Delete(1) + buf.Delete(1, cols, rows) } }, }, // Backspace { Key: ControlH, - Fn: func(buf *Buffer) { - buf.DeleteBeforeCursor(1) + Fn: func(buf *Buffer, cols istrings.Width, rows int) { + buf.DeleteBeforeCursor(1, cols, rows) }, }, // Right allow: Forward one character { Key: ControlF, - Fn: func(buf *Buffer) { - buf.CursorRight(1) + Fn: func(buf *Buffer, cols istrings.Width, rows int) { + buf.CursorRight(1, cols, rows) }, }, // Alt Right allow: Forward one word { Key: AltRight, - Fn: func(buf *Buffer) { - buf.CursorRight(buf.Document().FindRuneNumberUntilEndOfCurrentWord()) + Fn: func(buf *Buffer, cols istrings.Width, rows int) { + buf.CursorRight( + buf.Document().FindRuneNumberUntilEndOfCurrentWord(), + cols, + rows, + ) }, }, // Left allow: Backward one character { Key: ControlB, - Fn: func(buf *Buffer) { - buf.CursorLeft(1) + Fn: func(buf *Buffer, cols istrings.Width, rows int) { + buf.CursorLeft(1, cols, rows) }, }, // Alt Left allow: Backward one word { Key: AltLeft, - Fn: func(buf *Buffer) { - buf.CursorLeft(buf.Document().FindRuneNumberUntilStartOfPreviousWord()) + Fn: func(buf *Buffer, cols istrings.Width, rows int) { + buf.CursorLeft( + buf.Document().FindRuneNumberUntilStartOfPreviousWord(), + cols, + rows, + ) }, }, // Cut the Word before the cursor. { Key: ControlW, - Fn: func(buf *Buffer) { - buf.DeleteBeforeCursor(istrings.RuneCount(buf.Document().GetWordBeforeCursorWithSpace())) + Fn: func(buf *Buffer, cols istrings.Width, rows int) { + buf.DeleteBeforeCursor( + istrings.RuneCount(buf.Document().GetWordBeforeCursorWithSpace()), + cols, + rows, + ) }, }, { Key: AltBackspace, - Fn: func(buf *Buffer) { - buf.DeleteBeforeCursor(istrings.RuneCount(buf.Document().GetWordBeforeCursorWithSpace())) + Fn: func(buf *Buffer, cols istrings.Width, rows int) { + buf.DeleteBeforeCursor( + istrings.RuneCount(buf.Document().GetWordBeforeCursorWithSpace()), + cols, + rows, + ) }, }, // Clear the Screen, similar to the clear command { Key: ControlL, - Fn: func(buf *Buffer) { + Fn: func(buf *Buffer, cols istrings.Width, rows int) { consoleWriter.EraseScreen() consoleWriter.CursorGoTo(0, 0) debug.AssertNoError(consoleWriter.Flush()) diff --git a/emacs_test.go b/emacs_test.go index 605c3bd8..b5c84f57 100644 --- a/emacs_test.go +++ b/emacs_test.go @@ -8,7 +8,7 @@ import ( func TestEmacsKeyBindings(t *testing.T) { buf := NewBuffer() - buf.InsertText("abcde", false, true) + buf.InsertTextMoveCursor("abcde", DefColCount, DefRowCount, false) if buf.cursorPosition != istrings.RuneNumber(len("abcde")) { t.Errorf("Want %d, but got %d", len("abcde"), buf.cursorPosition) } @@ -30,7 +30,7 @@ func applyEmacsKeyBind(buf *Buffer, key Key) { for i := range emacsKeyBindings { kb := emacsKeyBindings[i] if kb.Key == key { - kb.Fn(buf) + kb.Fn(buf, DefColCount, DefRowCount) } } } diff --git a/history.go b/history.go index 9bf68d19..cb0034e3 100644 --- a/history.go +++ b/history.go @@ -1,5 +1,9 @@ package prompt +import ( + istrings "github.com/elk-language/go-prompt/strings" +) + // History stores the texts that are entered. type History struct { histories []string @@ -23,7 +27,7 @@ func (h *History) Clear() { // Older saves a buffer of current line and get a buffer of previous line by up-arrow. // The changes of line buffers are stored until new history is created. -func (h *History) Older(buf *Buffer) (new *Buffer, changed bool) { +func (h *History) Older(buf *Buffer, columns istrings.Width, rows int) (new *Buffer, changed bool) { if len(h.tmp) == 1 || h.selected == 0 { return buf, false } @@ -31,13 +35,13 @@ func (h *History) Older(buf *Buffer) (new *Buffer, changed bool) { h.selected-- new = NewBuffer() - new.InsertText(h.tmp[h.selected], false, true) + new.InsertTextMoveCursor(h.tmp[h.selected], columns, rows, false) return new, true } // Newer saves a buffer of current line and get a buffer of next line by up-arrow. // The changes of line buffers are stored until new history is created. -func (h *History) Newer(buf *Buffer) (new *Buffer, changed bool) { +func (h *History) Newer(buf *Buffer, columns istrings.Width, rows int) (new *Buffer, changed bool) { if h.selected >= len(h.tmp)-1 { return buf, false } @@ -45,7 +49,7 @@ func (h *History) Newer(buf *Buffer) (new *Buffer, changed bool) { h.selected++ new = NewBuffer() - new.InsertText(h.tmp[h.selected], false, true) + new.InsertTextMoveCursor(h.tmp[h.selected], columns, rows, false) return new, true } diff --git a/history_test.go b/history_test.go index bf638699..cda517bb 100644 --- a/history_test.go +++ b/history_test.go @@ -38,10 +38,10 @@ func TestHistoryOlder(t *testing.T) { // Prepare buffer buf := NewBuffer() - buf.InsertText("echo 2", false, true) + buf.InsertTextMoveCursor("echo 2", DefColCount, DefRowCount, false) // [1 time] Call Older function - buf1, changed := h.Older(buf) + buf1, changed := h.Older(buf, DefColCount, DefRowCount) if !changed { t.Error("Should be changed history but not changed.") } @@ -51,8 +51,8 @@ func TestHistoryOlder(t *testing.T) { // [2 times] Call Older function buf = NewBuffer() - buf.InsertText("echo 1", false, true) - buf2, changed := h.Older(buf) + buf.InsertTextMoveCursor("echo 1", DefColCount, DefRowCount, false) + buf2, changed := h.Older(buf, DefColCount, DefRowCount) if changed { t.Error("Should be not changed history but changed.") } diff --git a/key_bind.go b/key_bind.go index 0332e27d..9689d919 100644 --- a/key_bind.go +++ b/key_bind.go @@ -1,7 +1,11 @@ package prompt +import ( + istrings "github.com/elk-language/go-prompt/strings" +) + // KeyBindFunc receives buffer and processed it. -type KeyBindFunc func(*Buffer) +type KeyBindFunc func(buffer *Buffer, columns istrings.Width, rows int) // KeyBind represents which key should do what operation. type KeyBind struct { diff --git a/key_bind_func.go b/key_bind_func.go index 7b49f713..056e1b48 100644 --- a/key_bind_func.go +++ b/key_bind_func.go @@ -5,33 +5,33 @@ import ( ) // GoLineEnd Go to the End of the line -func GoLineEnd(buf *Buffer) { +func GoLineEnd(buf *Buffer, cols istrings.Width, rows int) { x := []rune(buf.Document().TextAfterCursor()) - buf.CursorRight(istrings.RuneNumber(len(x))) + buf.CursorRight(istrings.RuneNumber(len(x)), cols, rows) } // GoLineBeginning Go to the beginning of the line -func GoLineBeginning(buf *Buffer) { +func GoLineBeginning(buf *Buffer, cols istrings.Width, rows int) { x := []rune(buf.Document().TextBeforeCursor()) - buf.CursorLeft(istrings.RuneNumber(len(x))) + buf.CursorLeft(istrings.RuneNumber(len(x)), cols, rows) } // DeleteChar Delete character under the cursor -func DeleteChar(buf *Buffer) { - buf.Delete(1) +func DeleteChar(buf *Buffer, cols istrings.Width, rows int) { + buf.Delete(1, cols, rows) } // DeleteBeforeChar Go to Backspace -func DeleteBeforeChar(buf *Buffer) { - buf.DeleteBeforeCursor(1) +func DeleteBeforeChar(buf *Buffer, cols istrings.Width, rows int) { + buf.DeleteBeforeCursor(1, cols, rows) } // GoRightChar Forward one character -func GoRightChar(buf *Buffer) { - buf.CursorRight(1) +func GoRightChar(buf *Buffer, cols istrings.Width, rows int) { + buf.CursorRight(1, cols, rows) } // GoLeftChar Backward one character -func GoLeftChar(buf *Buffer) { - buf.CursorLeft(1) +func GoLeftChar(buf *Buffer, cols istrings.Width, rows int) { + buf.CursorLeft(1, cols, rows) } diff --git a/position.go b/position.go index 14ef66f2..787d41c7 100644 --- a/position.go +++ b/position.go @@ -47,13 +47,20 @@ 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 { + pos := positionAtEndOfReaderLine(strings.NewReader(str), columns, line) + return pos +} + +// positionAtEndOfString calculates the position +// at the end of the given string. func positionAtEndOfString(str string, columns istrings.Width) Position { pos := positionAtEndOfReader(strings.NewReader(str), columns) return pos } -// positionAtEndOfReader calculates the position of the -// p at the end of the given io.Reader. +// 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 @@ -93,3 +100,55 @@ charLoop: Y: down, } } + +// positionAtEndOfReaderLine calculates the position +// at the given line of the given io.Reader. +func positionAtEndOfReaderLine(reader io.RuneReader, columns istrings.Width, line int) Position { + var down int + var right istrings.Width + +charLoop: + for { + char, _, err := reader.ReadRune() + if err != nil { + break charLoop + } + + 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 + } + 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++ + } + } + } + + return Position{ + X: right, + Y: down, + } +} diff --git a/prompt.go b/prompt.go index f5c0fd2d..7483962c 100644 --- a/prompt.go +++ b/prompt.go @@ -121,6 +121,8 @@ func (p *Prompt) Run() { } case w := <-winSizeCh: p.renderer.UpdateWinSize(w) + p.buf.ResetStartLine() + p.buf.RecalculateStartLine(p.renderer.UserInputColumns(), int(p.renderer.row)) p.renderer.Render(p.buf, p.completion, p.lexer) case code := <-exitCh: p.renderer.BreakLine(p.buf, p.lexer) @@ -148,17 +150,21 @@ func (p *Prompt) feed(b []byte) (shouldExit bool, userInput *UserInput) { completing := p.completion.Completing() p.handleCompletionKeyBinding(b, key, completing) + cols := p.renderer.UserInputColumns() + rows := int(p.renderer.row) + switch key { case Enter, ControlJ, ControlM: indent, execute := p.executeOnEnterCallback(p.buf.Text(), p.renderer.indentSize) if !execute { - p.buf.NewLine(false) + p.buf.NewLine(cols, rows, false) + var indentStrBuilder strings.Builder indentUnitCount := indent * p.renderer.indentSize for i := 0; i < indentUnitCount; i++ { indentStrBuilder.WriteRune(IndentUnit) } - p.buf.InsertText(indentStrBuilder.String(), false, true) + p.buf.InsertTextMoveCursor(indentStrBuilder.String(), cols, rows, false) break } @@ -175,14 +181,14 @@ func (p *Prompt) feed(b []byte) (shouldExit bool, userInput *UserInput) { case Up, ControlP: line := p.buf.Document().CursorPositionRow() if line > 0 { - p.buf.CursorUp(1) + p.buf.CursorUp(1, cols, rows) break } if completing { break } - if newBuf, changed := p.history.Older(p.buf); changed { + if newBuf, changed := p.history.Older(p.buf, cols, rows); changed { p.buf = newBuf } @@ -190,7 +196,7 @@ func (p *Prompt) feed(b []byte) (shouldExit bool, userInput *UserInput) { endOfTextRow := p.buf.Document().TextEndPositionRow() row := p.buf.Document().CursorPositionRow() if endOfTextRow > row { - p.buf.CursorDown(1) + p.buf.CursorDown(1, cols, rows) break } @@ -198,7 +204,7 @@ func (p *Prompt) feed(b []byte) (shouldExit bool, userInput *UserInput) { break } - if newBuf, changed := p.history.Newer(p.buf); changed { + if newBuf, changed := p.history.Newer(p.buf, cols, rows); changed { p.buf = newBuf } return @@ -208,7 +214,7 @@ func (p *Prompt) feed(b []byte) (shouldExit bool, userInput *UserInput) { return } case NotDefined: - if p.handleASCIICodeBinding(b) { + if p.handleASCIICodeBinding(b, cols, rows) { return } char, _ := utf8.DecodeRune(b) @@ -216,14 +222,17 @@ func (p *Prompt) feed(b []byte) (shouldExit bool, userInput *UserInput) { return } - p.buf.InsertText(string(b), false, true) + p.buf.InsertTextMoveCursor(string(b), cols, rows, false) } - shouldExit = p.handleKeyBinding(key) + shouldExit = p.handleKeyBinding(key, cols, rows) return } func (p *Prompt) handleCompletionKeyBinding(b []byte, key Key, completing bool) { + cols := p.renderer.UserInputColumns() + rows := int(p.renderer.row) + keySwitch: switch key { case Down: @@ -255,7 +264,7 @@ keySwitch: newBytes = append(newBytes, byt) } } - p.buf.InsertText(string(newBytes), false, true) + p.buf.InsertTextMoveCursor(string(newBytes), cols, rows, false) case BackTab: if len(p.completion.GetSuggestions()) > 0 { // If there are any suggestions, select the previous one @@ -269,25 +278,25 @@ keySwitch: break keySwitch } } - p.buf.DeleteBeforeCursor(istrings.RuneNumber(p.renderer.indentSize)) + p.buf.DeleteBeforeCursor(istrings.RuneNumber(p.renderer.indentSize), cols, rows) default: if s, ok := p.completion.GetSelectedSuggestion(); ok { w := p.buf.Document().GetWordBeforeCursorUntilSeparator(p.completion.wordSeparator) if w != "" { - p.buf.DeleteBeforeCursor(istrings.RuneNumber(len([]rune(w)))) + p.buf.DeleteBeforeCursor(istrings.RuneNumber(len([]rune(w))), cols, rows) } - p.buf.InsertText(s.Text, false, true) + p.buf.InsertTextMoveCursor(s.Text, cols, rows, false) } p.completion.Reset() } } -func (p *Prompt) handleKeyBinding(key Key) bool { +func (p *Prompt) handleKeyBinding(key Key, cols istrings.Width, rows int) bool { shouldExit := false for i := range commonKeyBindings { kb := commonKeyBindings[i] if kb.Key == key { - kb.Fn(p.buf) + kb.Fn(p.buf, cols, rows) } } @@ -296,7 +305,7 @@ func (p *Prompt) handleKeyBinding(key Key) bool { for i := range emacsKeyBindings { kb := emacsKeyBindings[i] if kb.Key == key { - kb.Fn(p.buf) + kb.Fn(p.buf, cols, rows) } } } @@ -305,7 +314,7 @@ func (p *Prompt) handleKeyBinding(key Key) bool { for i := range p.keyBindings { kb := p.keyBindings[i] if kb.Key == key { - kb.Fn(p.buf) + kb.Fn(p.buf, cols, rows) } } if p.exitChecker != nil && p.exitChecker(p.buf.Text(), false) { @@ -314,11 +323,11 @@ func (p *Prompt) handleKeyBinding(key Key) bool { return shouldExit } -func (p *Prompt) handleASCIICodeBinding(b []byte) bool { +func (p *Prompt) handleASCIICodeBinding(b []byte, cols istrings.Width, rows int) bool { checked := false for _, kb := range p.ASCIICodeBindings { if bytes.Equal(kb.ASCIICode, b) { - kb.Fn(p.buf) + kb.Fn(p.buf, cols, rows) checked = true } } diff --git a/reader.go b/reader.go index a4a8b5f2..160ec511 100644 --- a/reader.go +++ b/reader.go @@ -11,6 +11,11 @@ type WinSize struct { Col uint16 } +const ( + DefColCount = 80 // Default column count of the terminal + DefRowCount = 25 // Default row count of the terminal +) + // Reader is an interface to abstract input layer. type Reader interface { // Open should be called before starting reading diff --git a/reader_posix.go b/reader_posix.go index a22d3590..cb209b25 100644 --- a/reader_posix.go +++ b/reader_posix.go @@ -58,8 +58,8 @@ func (t *PosixReader) GetWinSize() *WinSize { // If this errors, we simply return the default window size as // it's our best guess. return &WinSize{ - Row: 25, - Col: 80, + Row: DefRowCount, + Col: DefColCount, } } return &WinSize{ diff --git a/renderer.go b/renderer.go index 35805489..386121b9 100644 --- a/renderer.go +++ b/renderer.go @@ -16,7 +16,7 @@ type Renderer struct { prefixCallback PrefixCallback breakLineCallback func(*Document) title string - row uint16 + row int col istrings.Width indentSize int // How many spaces constitute a single indentation level @@ -106,7 +106,7 @@ func (r *Renderer) prepareArea(lines int) { // UpdateWinSize called when window size is changed. func (r *Renderer) UpdateWinSize(ws *WinSize) { - r.row = ws.Row + r.row = int(ws.Row) r.col = istrings.Width(ws.Col) } @@ -218,29 +218,31 @@ func (r *Renderer) Render(buffer *Buffer, completion *CompletionManager, lexer L text := buffer.Text() prefix := r.prefixCallback() prefixWidth := istrings.GetWidth(prefix) - cursor := positionAtEndOfString(text, r.col-prefixWidth) + col := r.col - prefixWidth + endLine := buffer.startLine + int(r.row) - 1 + cursor := positionAtEndOfStringLine(text, col, endLine) cursor.X += prefixWidth // prepare area - y := cursor.Y + // y := cursor.Y - h := y + 1 + int(completion.max) - if h > int(r.row) || completionMargin > r.col { - r.renderWindowTooSmall() - return - } + // h := y + 1 + int(completion.max) + // if h > int(r.row) || completionMargin > r.col { + // r.renderWindowTooSmall() + // return + // } // Rendering r.out.HideCursor() defer r.out.ShowCursor() - r.renderText(lexer, text) + r.renderText(lexer, buffer) r.out.SetColor(DefaultColor, DefaultColor, false) - targetCursor := buffer.DisplayCursorPosition(r.col - prefixWidth) + targetCursor := buffer.DisplayCursorPosition(col) targetCursor.X += prefixWidth - // Log("col: %#v, targetCursor: %#v, cursor: %#v\n", r.col-prefixWidth, targetCursor, cursor) + // Log("col: %#v, targetCursor: %#v, cursor: %#v\n", col, targetCursor, cursor) cursor = r.move(cursor, targetCursor) r.renderCompletion(buffer, completion) @@ -257,23 +259,24 @@ func (r *Renderer) Render(buffer *Buffer, completion *CompletionManager, lexer L rest := buffer.Document().TextAfterCursor() - r.renderText(lexer, text) + r.renderText(lexer, buffer) r.out.SetColor(DefaultColor, DefaultColor, false) - cursor = cursor.Join(positionAtEndOfString(rest, r.col-prefixWidth)) + cursor = cursor.Join(positionAtEndOfString(rest, col)) cursor = r.move(cursor, endOfSuggestionPos) } r.previousCursor = cursor } -func (r *Renderer) renderText(lexer Lexer, text string) { +func (r *Renderer) renderText(lexer Lexer, buffer *Buffer) { if lexer != nil { - r.lex(lexer, text) + r.lex(lexer, buffer) return } + text := buffer.Text() prefix := r.prefixCallback() prefixWidth := istrings.GetWidth(prefix) col := r.col - prefixWidth @@ -281,9 +284,17 @@ func (r *Renderer) renderText(lexer Lexer, text string) { firstIteration := true var lineBuffer strings.Builder var lineCharIndex istrings.Width + var lineNumber int for _, char := range text { if lineCharIndex >= col || char == '\n' { + lineNumber++ + if lineNumber < buffer.startLine { + continue + } + if lineNumber >= buffer.startLine+int(r.row) { + break + } lineBuffer.WriteRune('\n') r.renderLine(prefix, lineBuffer.String(), r.inputTextColor) lineCharIndex = 0 @@ -299,6 +310,9 @@ func (r *Renderer) renderText(lexer Lexer, text string) { continue } + if lineNumber < buffer.startLine { + continue + } lineBuffer.WriteRune(char) lineCharIndex += istrings.GetRuneWidth(char) } @@ -355,7 +369,8 @@ func (r *Renderer) getMultilinePrefix(prefix string) string { // lex processes the given input with the given lexer // and writes the result -func (r *Renderer) lex(lexer Lexer, input string) { +func (r *Renderer) lex(lexer Lexer, buffer *Buffer) { + input := buffer.Text() lexer.Init(input) s := input @@ -408,8 +423,7 @@ func (r *Renderer) BreakLine(buffer *Buffer, lexer Lexer) { cursor.X += prefixWidth r.clear(cursor) - text := buffer.Document().Text - r.renderText(lexer, text) + r.renderText(lexer, buffer) if _, err := r.out.WriteString("\n"); err != nil { panic(err) } @@ -424,6 +438,12 @@ func (r *Renderer) BreakLine(buffer *Buffer, lexer Lexer) { r.previousCursor = Position{} } +// Get the number of columns that are available +// for user input. +func (r *Renderer) UserInputColumns() istrings.Width { + return r.col - istrings.GetWidth(r.prefixCallback()) +} + // clear erases the screen from a beginning of input // even if there is line break which means input length exceeds a window's width. func (r *Renderer) clear(cursor Position) { From b80577bf450a2a1ce0a0b9919ed2289cee86aa2a Mon Sep 17 00:00:00 2001 From: Mateusz Drewniak Date: Sun, 16 Jul 2023 23:30:44 +0200 Subject: [PATCH 02/16] Add test for positionAtEndOfStringLine --- position_test.go | 53 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/position_test.go b/position_test.go index ce2ed7f4..0ea6dd31 100644 --- a/position_test.go +++ b/position_test.go @@ -76,6 +76,59 @@ func TestPositionAtEndOfString(t *testing.T) { } } +func TestPositionAtEndOfStringLine(t *testing.T) { + tests := map[string]struct { + input string + cols istrings.Width + line int + want Position + }{ + "last line overflows": { + input: `hi +foobar`, + cols: 3, + line: 1, + want: Position{ + X: 2, + Y: 1, + }, + }, + "last line is in the middle": { + input: `hi +foo hey +bar boo ba +baz`, + cols: 20, + line: 2, + want: Position{ + X: 10, + Y: 2, + }, + }, + "last line is out of bounds": { + input: `hi +foo hey +bar boo ba +baz`, + cols: 20, + line: 20, + want: Position{ + X: 3, + Y: 3, + }, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + got := positionAtEndOfStringLine(tc.input, tc.cols, tc.line) + if diff := cmp.Diff(tc.want, got); diff != "" { + t.Fatalf(diff) + } + }) + } +} + func TestPositionAdd(t *testing.T) { tests := map[string]struct { left Position From a7275ed7e6d3cf9e303a7eeca22c62386d9cbdf3 Mon Sep 17 00:00:00 2001 From: Mateusz Drewniak Date: Mon, 17 Jul 2023 09:24:03 +0200 Subject: [PATCH 03/16] Fix buffer scrolling for text with syntax higlighting --- _example/even-lexer/main.go | 7 ++----- prompt.go | 18 ++++++++---------- renderer.go | 31 ++++++++++++++++++------------- 3 files changed, 28 insertions(+), 28 deletions(-) diff --git a/_example/even-lexer/main.go b/_example/even-lexer/main.go index ac82b5f4..1efc7738 100644 --- a/_example/even-lexer/main.go +++ b/_example/even-lexer/main.go @@ -2,7 +2,6 @@ package main import ( "fmt" - "strings" "github.com/elk-language/go-prompt" ) @@ -19,9 +18,7 @@ func main() { func lexer(line string) []prompt.Token { var elements []prompt.Token - strArr := strings.Split(line, "") - - for i, value := range strArr { + for i, value := range line { var color prompt.Color // every even char must be green. if i%2 == 0 { @@ -29,7 +26,7 @@ func lexer(line string) []prompt.Token { } else { color = prompt.White } - element := prompt.NewSimpleToken(color, value) + element := prompt.NewSimpleToken(color, string(value)) elements = append(elements, element) } diff --git a/prompt.go b/prompt.go index 7483962c..63c82b7d 100644 --- a/prompt.go +++ b/prompt.go @@ -2,8 +2,6 @@ package prompt import ( "bytes" - "fmt" - "log" "os" "strings" "time" @@ -134,14 +132,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, 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, a...) +// } func (p *Prompt) feed(b []byte) (shouldExit bool, userInput *UserInput) { key := GetKey(b) diff --git a/renderer.go b/renderer.go index 386121b9..9ab8fa31 100644 --- a/renderer.go +++ b/renderer.go @@ -223,15 +223,6 @@ func (r *Renderer) Render(buffer *Buffer, completion *CompletionManager, lexer L cursor := positionAtEndOfStringLine(text, col, endLine) cursor.X += prefixWidth - // prepare area - // y := cursor.Y - - // h := y + 1 + int(completion.max) - // if h > int(r.row) || completionMargin > r.col { - // r.renderWindowTooSmall() - // return - // } - // Rendering r.out.HideCursor() defer r.out.ShowCursor() @@ -282,6 +273,7 @@ func (r *Renderer) renderText(lexer Lexer, buffer *Buffer) { col := r.col - prefixWidth multilinePrefix := r.getMultilinePrefix(prefix) firstIteration := true + endLine := buffer.startLine + int(r.row) var lineBuffer strings.Builder var lineCharIndex istrings.Width var lineNumber int @@ -292,7 +284,7 @@ func (r *Renderer) renderText(lexer Lexer, buffer *Buffer) { if lineNumber < buffer.startLine { continue } - if lineNumber >= buffer.startLine+int(r.row) { + if lineNumber >= endLine { break } lineBuffer.WriteRune('\n') @@ -380,6 +372,10 @@ func (r *Renderer) lex(lexer Lexer, buffer *Buffer) { multilinePrefix := r.getMultilinePrefix(prefix) r.renderPrefix(prefix) var lineCharIndex istrings.Width + var lineNumber int + endLine := buffer.startLine + int(r.row) + +tokenLoop: for { token, ok := lexer.Next() if !ok { @@ -391,11 +387,17 @@ func (r *Renderer) lex(lexer Lexer, buffer *Buffer) { var lineBuffer strings.Builder + charLoop: for _, char := range text { if lineCharIndex >= col || char == '\n' { - if char != '\n' { - lineBuffer.WriteByte('\n') + lineNumber++ + if lineNumber < buffer.startLine { + continue charLoop } + if lineNumber >= endLine { + break tokenLoop + } + lineBuffer.WriteByte('\n') r.writeString(lineBuffer.String(), token.Color()) r.renderPrefix(multilinePrefix) lineCharIndex = 0 @@ -404,9 +406,12 @@ func (r *Renderer) lex(lexer Lexer, buffer *Buffer) { lineBuffer.WriteRune(char) lineCharIndex += istrings.GetRuneWidth(char) } - continue + continue charLoop } + if lineNumber < buffer.startLine { + continue charLoop + } lineBuffer.WriteRune(char) lineCharIndex += istrings.GetRuneWidth(char) } From 4291ed48d590d7eb1d6a49e93bf332ef6d774171 Mon Sep 17 00:00:00 2001 From: Mateusz Drewniak Date: Sat, 22 Jul 2023 21:19:01 +0200 Subject: [PATCH 04/16] Avoid rerendering the screen when moving the cursor --- buffer.go | 35 +------ constructor.go | 4 +- emacs.go | 92 ++++++++++------- key_bind.go | 6 +- key_bind_func.go | 30 +++--- prompt.go | 257 ++++++++++++++++++++++++++++++++--------------- renderer.go | 12 ++- 7 files changed, 259 insertions(+), 177 deletions(-) diff --git a/buffer.go b/buffer.go index 102af5bb..42ae5694 100644 --- a/buffer.go +++ b/buffer.go @@ -88,7 +88,9 @@ func (b *Buffer) ResetStartLine() { b.startLine = 0 } -func (b *Buffer) RecalculateStartLine(columns istrings.Width, rows int) { +// Calculates the startLine once again and returns true when it's been changed. +func (b *Buffer) RecalculateStartLine(columns istrings.Width, rows int) bool { + origStartLine := b.startLine pos := b.DisplayCursorPosition(columns) if pos.Y > b.startLine+rows-1 { b.startLine = pos.Y - rows + 1 @@ -99,6 +101,7 @@ func (b *Buffer) RecalculateStartLine(columns istrings.Width, rows int) { if b.startLine < 0 { b.startLine = 0 } + return origStartLine != b.startLine } // SetText method to set text and update cursorPosition. @@ -126,36 +129,6 @@ func (b *Buffer) setDocument(d *Document, columns istrings.Width, rows int) { b.RecalculateStartLine(columns, rows) } -// CursorLeft move to left on the current line. -func (b *Buffer) CursorLeft(count istrings.RuneNumber, columns istrings.Width, rows int) { - l := b.Document().GetCursorLeftPosition(count) - b.cursorPosition += l - b.RecalculateStartLine(columns, rows) -} - -// CursorRight move to right on the current line. -func (b *Buffer) CursorRight(count istrings.RuneNumber, columns istrings.Width, rows int) { - l := b.Document().GetCursorRightPosition(count) - b.cursorPosition += l - b.RecalculateStartLine(columns, rows) -} - -// CursorUp move cursor to the previous line. -// (for multi-line edit). -func (b *Buffer) CursorUp(count int, columns istrings.Width, rows int) { - orig := b.Document().CursorPositionCol() - b.cursorPosition += b.Document().GetCursorUpPosition(count, orig) - b.RecalculateStartLine(columns, rows) -} - -// CursorDown move cursor to the next line. -// (for multi-line edit). -func (b *Buffer) CursorDown(count int, columns istrings.Width, rows int) { - orig := b.Document().CursorPositionCol() - b.cursorPosition += b.Document().GetCursorDownPosition(count, orig) - 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) { debug.Assert(count >= 0, "count should be positive") diff --git a/constructor.go b/constructor.go index cb66d643..2da19034 100644 --- a/constructor.go +++ b/constructor.go @@ -80,7 +80,7 @@ func WithPrefix(prefix string) Option { // WithInitialText can be used to set the initial buffer text. func WithInitialText(text string) Option { return func(p *Prompt) error { - p.buf.InsertTextMoveCursor(text, p.renderer.col, int(p.renderer.row), true) + p.Buffer.InsertTextMoveCursor(text, p.renderer.col, int(p.renderer.row), true) return nil } } @@ -315,7 +315,7 @@ func New(executor Executor, opts ...Option) *Prompt { pt := &Prompt{ reader: NewStdinReader(), renderer: NewRenderer(), - buf: NewBuffer(), + Buffer: NewBuffer(), executor: executor, history: NewHistory(), completion: NewCompletionManager(6), diff --git a/emacs.go b/emacs.go index a7f4550f..3314d273 100644 --- a/emacs.go +++ b/emacs.go @@ -45,111 +45,127 @@ var emacsKeyBindings = []KeyBind{ // Go to the End of the line { Key: ControlE, - Fn: func(buf *Buffer, cols istrings.Width, rows int) { - buf.CursorRight(istrings.RuneCount(buf.Document().CurrentLineAfterCursor()), cols, rows) + Fn: func(p *Prompt) bool { + return p.CursorRight( + istrings.RuneCount(p.Buffer.Document().CurrentLineAfterCursor()), + ) }, }, // Go to the beginning of the line { Key: ControlA, - Fn: func(buf *Buffer, cols istrings.Width, rows int) { - buf.CursorLeft(buf.Document().FindStartOfFirstWordOfLine(), cols, rows) + Fn: func(p *Prompt) bool { + return p.CursorLeft( + p.Buffer.Document().FindStartOfFirstWordOfLine(), + ) }, }, // Cut the Line after the cursor { Key: ControlK, - Fn: func(buf *Buffer, cols istrings.Width, rows int) { - buf.Delete(istrings.RuneCount(buf.Document().CurrentLineAfterCursor()), cols, rows) + Fn: func(p *Prompt) bool { + p.Buffer.Delete( + istrings.RuneCount(p.Buffer.Document().CurrentLineAfterCursor()), + p.renderer.col, + p.renderer.row, + ) + return true }, }, // Cut/delete the Line before the cursor { Key: ControlU, - Fn: func(buf *Buffer, cols istrings.Width, rows int) { - buf.DeleteBeforeCursor(istrings.RuneCount(buf.Document().CurrentLineBeforeCursor()), cols, rows) + Fn: func(p *Prompt) bool { + p.Buffer.DeleteBeforeCursor( + istrings.RuneCount(p.Buffer.Document().CurrentLineBeforeCursor()), + p.renderer.col, + p.renderer.row, + ) + return true }, }, // Delete character under the cursor { Key: ControlD, - Fn: func(buf *Buffer, cols istrings.Width, rows int) { - if buf.Text() != "" { - buf.Delete(1, cols, rows) + Fn: func(p *Prompt) bool { + if p.Buffer.Text() != "" { + p.Buffer.Delete(1, p.renderer.col, p.renderer.row) + return true } + return false }, }, // Backspace { Key: ControlH, - Fn: func(buf *Buffer, cols istrings.Width, rows int) { - buf.DeleteBeforeCursor(1, cols, rows) + Fn: func(p *Prompt) bool { + p.Buffer.DeleteBeforeCursor(1, p.renderer.col, p.renderer.row) + return true }, }, // Right allow: Forward one character { Key: ControlF, - Fn: func(buf *Buffer, cols istrings.Width, rows int) { - buf.CursorRight(1, cols, rows) + Fn: func(p *Prompt) bool { + return p.CursorRight(1) }, }, // Alt Right allow: Forward one word { Key: AltRight, - Fn: func(buf *Buffer, cols istrings.Width, rows int) { - buf.CursorRight( - buf.Document().FindRuneNumberUntilEndOfCurrentWord(), - cols, - rows, + Fn: func(p *Prompt) bool { + return p.CursorRight( + p.Buffer.Document().FindRuneNumberUntilEndOfCurrentWord(), ) }, }, // Left allow: Backward one character { Key: ControlB, - Fn: func(buf *Buffer, cols istrings.Width, rows int) { - buf.CursorLeft(1, cols, rows) + Fn: func(p *Prompt) bool { + return p.CursorLeft(1) }, }, // Alt Left allow: Backward one word { Key: AltLeft, - Fn: func(buf *Buffer, cols istrings.Width, rows int) { - buf.CursorLeft( - buf.Document().FindRuneNumberUntilStartOfPreviousWord(), - cols, - rows, + Fn: func(p *Prompt) bool { + return p.CursorLeft( + p.Buffer.Document().FindRuneNumberUntilStartOfPreviousWord(), ) }, }, // Cut the Word before the cursor. { Key: ControlW, - Fn: func(buf *Buffer, cols istrings.Width, rows int) { - buf.DeleteBeforeCursor( - istrings.RuneCount(buf.Document().GetWordBeforeCursorWithSpace()), - cols, - rows, + Fn: func(p *Prompt) bool { + p.Buffer.DeleteBeforeCursor( + istrings.RuneCount(p.Buffer.Document().GetWordBeforeCursorWithSpace()), + p.renderer.col, + p.renderer.row, ) + return true }, }, { Key: AltBackspace, - Fn: func(buf *Buffer, cols istrings.Width, rows int) { - buf.DeleteBeforeCursor( - istrings.RuneCount(buf.Document().GetWordBeforeCursorWithSpace()), - cols, - rows, + Fn: func(p *Prompt) bool { + p.Buffer.DeleteBeforeCursor( + istrings.RuneCount(p.Buffer.Document().GetWordBeforeCursorWithSpace()), + p.renderer.col, + p.renderer.row, ) + return true }, }, // Clear the Screen, similar to the clear command { Key: ControlL, - Fn: func(buf *Buffer, cols istrings.Width, rows int) { + Fn: func(p *Prompt) bool { consoleWriter.EraseScreen() consoleWriter.CursorGoTo(0, 0) debug.AssertNoError(consoleWriter.Flush()) + return true }, }, } diff --git a/key_bind.go b/key_bind.go index 9689d919..7bd7c0fc 100644 --- a/key_bind.go +++ b/key_bind.go @@ -1,11 +1,7 @@ package prompt -import ( - istrings "github.com/elk-language/go-prompt/strings" -) - // KeyBindFunc receives buffer and processed it. -type KeyBindFunc func(buffer *Buffer, columns istrings.Width, rows int) +type KeyBindFunc func(p *Prompt) (rerender bool) // KeyBind represents which key should do what operation. type KeyBind struct { diff --git a/key_bind_func.go b/key_bind_func.go index 056e1b48..6f604adf 100644 --- a/key_bind_func.go +++ b/key_bind_func.go @@ -5,33 +5,35 @@ import ( ) // GoLineEnd Go to the End of the line -func GoLineEnd(buf *Buffer, cols istrings.Width, rows int) { - x := []rune(buf.Document().TextAfterCursor()) - buf.CursorRight(istrings.RuneNumber(len(x)), cols, rows) +func GoLineEnd(p *Prompt) bool { + x := []rune(p.Buffer.Document().TextAfterCursor()) + return p.CursorRight(istrings.RuneNumber(len(x))) } // GoLineBeginning Go to the beginning of the line -func GoLineBeginning(buf *Buffer, cols istrings.Width, rows int) { - x := []rune(buf.Document().TextBeforeCursor()) - buf.CursorLeft(istrings.RuneNumber(len(x)), cols, rows) +func GoLineBeginning(p *Prompt) bool { + x := []rune(p.Buffer.Document().TextBeforeCursor()) + return p.CursorLeft(istrings.RuneNumber(len(x))) } // DeleteChar Delete character under the cursor -func DeleteChar(buf *Buffer, cols istrings.Width, rows int) { - buf.Delete(1, cols, rows) +func DeleteChar(p *Prompt) bool { + p.Buffer.Delete(1, p.renderer.col, p.renderer.row) + return true } // DeleteBeforeChar Go to Backspace -func DeleteBeforeChar(buf *Buffer, cols istrings.Width, rows int) { - buf.DeleteBeforeCursor(1, cols, rows) +func DeleteBeforeChar(p *Prompt) bool { + p.Buffer.DeleteBeforeCursor(1, p.renderer.col, p.renderer.row) + return true } // GoRightChar Forward one character -func GoRightChar(buf *Buffer, cols istrings.Width, rows int) { - buf.CursorRight(1, cols, rows) +func GoRightChar(p *Prompt) bool { + return p.CursorRight(1) } // GoLeftChar Backward one character -func GoLeftChar(buf *Buffer, cols istrings.Width, rows int) { - buf.CursorLeft(1, cols, rows) +func GoLeftChar(p *Prompt) bool { + return p.CursorLeft(1) } diff --git a/prompt.go b/prompt.go index 63c82b7d..e00fd275 100644 --- a/prompt.go +++ b/prompt.go @@ -2,6 +2,8 @@ package prompt import ( "bytes" + "fmt" + "log" "os" "strings" "time" @@ -40,7 +42,7 @@ type Completer func(Document) []Suggest // Prompt is a core struct of go-prompt. type Prompt struct { reader Reader - buf *Buffer + Buffer *Buffer renderer *Renderer executor Executor history *History @@ -69,10 +71,10 @@ func (p *Prompt) Run() { defer p.Close() if p.completion.showAtStart { - p.completion.Update(*p.buf.Document()) + p.completion.Update(*p.Buffer.Document()) } - p.renderer.Render(p.buf, p.completion, p.lexer) + p.renderer.Render(p.Buffer, p.completion, p.lexer) bufCh := make(chan []byte, 128) stopReadBufCh := make(chan struct{}) @@ -86,12 +88,12 @@ func (p *Prompt) Run() { for { select { case b := <-bufCh: - if shouldExit, e := p.feed(b); shouldExit { - p.renderer.BreakLine(p.buf, p.lexer) + if shouldExit, rerender, input := p.feed(b); shouldExit { + p.renderer.BreakLine(p.Buffer, p.lexer) stopReadBufCh <- struct{}{} stopHandleSignalCh <- struct{}{} return - } else if e != nil { + } else if input != nil { // Stop goroutine to run readBuffer function stopReadBufCh <- struct{}{} stopHandleSignalCh <- struct{}{} @@ -99,13 +101,13 @@ func (p *Prompt) Run() { // Unset raw mode // Reset to Blocking mode because returned EAGAIN when still set non-blocking mode. debug.AssertNoError(p.reader.Close()) - p.executor(e.input) + p.executor(input.input) - p.completion.Update(*p.buf.Document()) + p.completion.Update(*p.Buffer.Document()) - p.renderer.Render(p.buf, p.completion, p.lexer) + p.renderer.Render(p.Buffer, p.completion, p.lexer) - if p.exitChecker != nil && p.exitChecker(e.input, true) { + if p.exitChecker != nil && p.exitChecker(input.input, true) { p.skipClose = true return } @@ -113,17 +115,18 @@ func (p *Prompt) Run() { debug.AssertNoError(p.reader.Open()) go p.readBuffer(bufCh, stopReadBufCh) go p.handleSignals(exitCh, winSizeCh, stopHandleSignalCh) - } else { - p.completion.Update(*p.buf.Document()) - p.renderer.Render(p.buf, p.completion, p.lexer) + } else if rerender { + Log("rerender") + p.completion.Update(*p.Buffer.Document()) + p.renderer.Render(p.Buffer, p.completion, p.lexer) } case w := <-winSizeCh: p.renderer.UpdateWinSize(w) - p.buf.ResetStartLine() - p.buf.RecalculateStartLine(p.renderer.UserInputColumns(), int(p.renderer.row)) - p.renderer.Render(p.buf, p.completion, p.lexer) + p.Buffer.ResetStartLine() + p.Buffer.RecalculateStartLine(p.renderer.UserInputColumns(), int(p.renderer.row)) + p.renderer.Render(p.Buffer, p.completion, p.lexer) case code := <-exitCh: - p.renderer.BreakLine(p.buf, p.lexer) + p.renderer.BreakLine(p.Buffer, p.lexer) p.Close() os.Exit(code) default: @@ -132,99 +135,102 @@ 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, 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, userInput *UserInput) { +func (p *Prompt) feed(b []byte) (shouldExit bool, rerender bool, userInput *UserInput) { key := GetKey(b) - p.buf.lastKeyStroke = key + p.Buffer.lastKeyStroke = key // completion completing := p.completion.Completing() p.handleCompletionKeyBinding(b, key, completing) cols := p.renderer.UserInputColumns() - rows := int(p.renderer.row) + rows := p.renderer.row + rerender = true switch key { case Enter, ControlJ, ControlM: - indent, execute := p.executeOnEnterCallback(p.buf.Text(), p.renderer.indentSize) + indent, execute := p.executeOnEnterCallback(p.Buffer.Text(), p.renderer.indentSize) if !execute { - p.buf.NewLine(cols, rows, false) + p.Buffer.NewLine(cols, rows, false) var indentStrBuilder strings.Builder indentUnitCount := indent * p.renderer.indentSize for i := 0; i < indentUnitCount; i++ { indentStrBuilder.WriteRune(IndentUnit) } - p.buf.InsertTextMoveCursor(indentStrBuilder.String(), cols, rows, false) + p.Buffer.InsertTextMoveCursor(indentStrBuilder.String(), cols, rows, false) break } - p.renderer.BreakLine(p.buf, p.lexer) - userInput = &UserInput{input: p.buf.Text()} - p.buf = NewBuffer() + p.renderer.BreakLine(p.Buffer, p.lexer) + userInput = &UserInput{input: p.Buffer.Text()} + p.Buffer = NewBuffer() if userInput.input != "" { p.history.Add(userInput.input) } case ControlC: - p.renderer.BreakLine(p.buf, p.lexer) - p.buf = NewBuffer() + p.renderer.BreakLine(p.Buffer, p.lexer) + p.Buffer = NewBuffer() p.history.Clear() case Up, ControlP: - line := p.buf.Document().CursorPositionRow() + line := p.Buffer.Document().CursorPositionRow() if line > 0 { - p.buf.CursorUp(1, cols, rows) - break + rerender = p.CursorUp(1) + return false, rerender, nil } if completing { break } - if newBuf, changed := p.history.Older(p.buf, cols, rows); changed { - p.buf = newBuf + if newBuf, changed := p.history.Older(p.Buffer, cols, rows); changed { + p.Buffer = newBuf } case Down, ControlN: - endOfTextRow := p.buf.Document().TextEndPositionRow() - row := p.buf.Document().CursorPositionRow() + endOfTextRow := p.Buffer.Document().TextEndPositionRow() + row := p.Buffer.Document().CursorPositionRow() if endOfTextRow > row { - p.buf.CursorDown(1, cols, rows) - break + rerender = p.CursorDown(1) + return false, rerender, nil } if completing { break } - if newBuf, changed := p.history.Newer(p.buf, cols, rows); changed { - p.buf = newBuf + if newBuf, changed := p.history.Newer(p.Buffer, cols, rows); changed { + p.Buffer = newBuf } - return + return false, true, nil case ControlD: - if p.buf.Text() == "" { - shouldExit = true - return + if p.Buffer.Text() == "" { + return true, true, nil } case NotDefined: - if p.handleASCIICodeBinding(b, cols, rows) { - return + var checked bool + checked, rerender = p.handleASCIICodeBinding(b, cols, rows) + + if checked { + return false, rerender, nil } char, _ := utf8.DecodeRune(b) if unicode.IsControl(char) { - return + return false, false, nil } - p.buf.InsertTextMoveCursor(string(b), cols, rows, false) + p.Buffer.InsertTextMoveCursor(string(b), cols, rows, false) } - shouldExit = p.handleKeyBinding(key, cols, rows) - return + shouldExit, rerender = p.handleKeyBinding(key, cols, rows) + return shouldExit, rerender, userInput } func (p *Prompt) handleCompletionKeyBinding(b []byte, key Key, completing bool) { @@ -262,7 +268,7 @@ keySwitch: newBytes = append(newBytes, byt) } } - p.buf.InsertTextMoveCursor(string(newBytes), cols, rows, false) + p.Buffer.InsertTextMoveCursor(string(newBytes), cols, rows, false) case BackTab: if len(p.completion.GetSuggestions()) > 0 { // If there are any suggestions, select the previous one @@ -270,31 +276,35 @@ keySwitch: break } - text := p.buf.Document().CurrentLineBeforeCursor() + text := p.Buffer.Document().CurrentLineBeforeCursor() for _, char := range text { if char != IndentUnit { break keySwitch } } - p.buf.DeleteBeforeCursor(istrings.RuneNumber(p.renderer.indentSize), cols, rows) + p.Buffer.DeleteBeforeCursor(istrings.RuneNumber(p.renderer.indentSize), cols, rows) default: if s, ok := p.completion.GetSelectedSuggestion(); ok { - w := p.buf.Document().GetWordBeforeCursorUntilSeparator(p.completion.wordSeparator) + w := p.Buffer.Document().GetWordBeforeCursorUntilSeparator(p.completion.wordSeparator) if w != "" { - p.buf.DeleteBeforeCursor(istrings.RuneNumber(len([]rune(w))), cols, rows) + p.Buffer.DeleteBeforeCursor(istrings.RuneNumber(len([]rune(w))), cols, rows) } - p.buf.InsertTextMoveCursor(s.Text, cols, rows, false) + p.Buffer.InsertTextMoveCursor(s.Text, cols, rows, false) } p.completion.Reset() } } -func (p *Prompt) handleKeyBinding(key Key, cols istrings.Width, rows int) bool { - shouldExit := false +func (p *Prompt) handleKeyBinding(key Key, cols istrings.Width, rows int) (shouldExit bool, rerender bool) { + var executed bool for i := range commonKeyBindings { kb := commonKeyBindings[i] if kb.Key == key { - kb.Fn(p.buf, cols, rows) + result := kb.Fn(p) + executed = true + if !rerender { + rerender = result + } } } @@ -303,7 +313,11 @@ func (p *Prompt) handleKeyBinding(key Key, cols istrings.Width, rows int) bool { for i := range emacsKeyBindings { kb := emacsKeyBindings[i] if kb.Key == key { - kb.Fn(p.buf, cols, rows) + result := kb.Fn(p) + executed = true + if !rerender { + rerender = result + } } } } @@ -312,24 +326,33 @@ func (p *Prompt) handleKeyBinding(key Key, cols istrings.Width, rows int) bool { for i := range p.keyBindings { kb := p.keyBindings[i] if kb.Key == key { - kb.Fn(p.buf, cols, rows) + result := kb.Fn(p) + executed = true + if !rerender { + rerender = result + } } } - if p.exitChecker != nil && p.exitChecker(p.buf.Text(), false) { + if p.exitChecker != nil && p.exitChecker(p.Buffer.Text(), false) { shouldExit = true } - return shouldExit + if !executed && !rerender { + rerender = true + } + return shouldExit, rerender } -func (p *Prompt) handleASCIICodeBinding(b []byte, cols istrings.Width, rows int) bool { - checked := false +func (p *Prompt) handleASCIICodeBinding(b []byte, cols istrings.Width, rows int) (checked, rerender bool) { for _, kb := range p.ASCIICodeBindings { if bytes.Equal(kb.ASCIICode, b) { - kb.Fn(p.buf, cols, rows) + result := kb.Fn(p) + if !rerender { + rerender = result + } checked = true } } - return checked + return checked, rerender } // Input starts the prompt, lets the user @@ -341,10 +364,10 @@ func (p *Prompt) Input() string { defer p.Close() if p.completion.showAtStart { - p.completion.Update(*p.buf.Document()) + p.completion.Update(*p.Buffer.Document()) } - p.renderer.Render(p.buf, p.completion, p.lexer) + p.renderer.Render(p.Buffer, p.completion, p.lexer) bufCh := make(chan []byte, 128) stopReadBufCh := make(chan struct{}) go p.readBuffer(bufCh, stopReadBufCh) @@ -352,17 +375,17 @@ func (p *Prompt) Input() string { for { select { case b := <-bufCh: - if shouldExit, e := p.feed(b); shouldExit { - p.renderer.BreakLine(p.buf, p.lexer) + if shouldExit, rerender, input := p.feed(b); shouldExit { + p.renderer.BreakLine(p.Buffer, p.lexer) stopReadBufCh <- struct{}{} return "" - } else if e != nil { + } else if input != nil { // Stop goroutine to run readBuffer function stopReadBufCh <- struct{}{} - return e.input - } else { - p.completion.Update(*p.buf.Document()) - p.renderer.Render(p.buf, p.completion, p.lexer) + return input.input + } else if rerender { + p.completion.Update(*p.Buffer.Document()) + p.renderer.Render(p.Buffer, p.completion, p.lexer) } default: time.Sleep(10 * time.Millisecond) @@ -423,6 +446,74 @@ func (p *Prompt) setup() { p.renderer.UpdateWinSize(p.reader.GetWinSize()) } +// Move to the left on the current line. +// 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) + + l := b.Document().GetCursorLeftPosition(count) + b.cursorPosition += l + + newCursor := b.DisplayCursorPosition(cols) + p.renderer.previousCursor = newCursor + p.renderer.move(previousCursor, newCursor) + p.renderer.flush() + return b.RecalculateStartLine(cols, p.renderer.row) +} + +// Move the cursor to the right on the current line. +// Returns true when the view should be rerendered. +func (p *Prompt) CursorRight(count istrings.RuneNumber) bool { + b := p.Buffer + cols := p.renderer.UserInputColumns() + previousCursor := b.DisplayCursorPosition(cols) + + l := b.Document().GetCursorRightPosition(count) + b.cursorPosition += l + + newCursor := b.DisplayCursorPosition(cols) + p.renderer.previousCursor = newCursor + p.renderer.move(previousCursor, newCursor) + p.renderer.flush() + return b.RecalculateStartLine(cols, p.renderer.row) +} + +// Move the cursor up. +// Returns true when the view should be rerendered. +func (p *Prompt) CursorUp(count int) bool { + b := p.Buffer + cols := p.renderer.UserInputColumns() + previousCursor := b.DisplayCursorPosition(cols) + + orig := b.Document().CursorPositionCol() + b.cursorPosition += b.Document().GetCursorUpPosition(count, orig) + + newCursor := b.DisplayCursorPosition(cols) + p.renderer.previousCursor = newCursor + p.renderer.move(previousCursor, newCursor) + p.renderer.flush() + return b.RecalculateStartLine(cols, p.renderer.row) +} + +// Move the cursor down. +// Returns true when the view should be rerendered. +func (p *Prompt) CursorDown(count int) bool { + b := p.Buffer + cols := p.renderer.UserInputColumns() + previousCursor := b.DisplayCursorPosition(cols) + + orig := b.Document().CursorPositionCol() + b.cursorPosition += b.Document().GetCursorDownPosition(count, orig) + + newCursor := b.DisplayCursorPosition(cols) + p.renderer.previousCursor = newCursor + p.renderer.move(previousCursor, newCursor) + p.renderer.flush() + return b.RecalculateStartLine(cols, p.renderer.row) +} + func (p *Prompt) Close() { if !p.skipClose { debug.AssertNoError(p.reader.Close()) diff --git a/renderer.go b/renderer.go index 9ab8fa31..b04fbd7d 100644 --- a/renderer.go +++ b/renderer.go @@ -73,7 +73,7 @@ func NewRenderer() *Renderer { func (r *Renderer) Setup() { if r.title != "" { r.out.SetTitle(r.title) - debug.AssertNoError(r.out.Flush()) + r.flush() } } @@ -92,7 +92,7 @@ func (r *Renderer) renderPrefix(prefix string) { func (r *Renderer) Close() { r.out.ClearTitle() r.out.EraseDown() - debug.AssertNoError(r.out.Flush()) + r.flush() } func (r *Renderer) prepareArea(lines int) { @@ -212,7 +212,7 @@ func (r *Renderer) Render(buffer *Buffer, completion *CompletionManager, lexer L if r.col == 0 { return } - defer func() { debug.AssertNoError(r.out.Flush()) }() + defer func() { r.flush() }() r.clear(r.previousCursor) text := buffer.Text() @@ -312,6 +312,10 @@ func (r *Renderer) renderText(lexer Lexer, buffer *Buffer) { r.renderLine(prefix, lineBuffer.String(), r.inputTextColor) } +func (r *Renderer) flush() { + debug.AssertNoError(r.out.Flush()) +} + func (r *Renderer) renderLine(prefix, line string, color Color) { r.renderPrefix(prefix) r.writeString(line, color) @@ -435,7 +439,7 @@ func (r *Renderer) BreakLine(buffer *Buffer, lexer Lexer) { r.out.SetColor(DefaultColor, DefaultColor, false) - debug.AssertNoError(r.out.Flush()) + r.flush() if r.breakLineCallback != nil { r.breakLineCallback(buffer.Document()) } From 13eba6b7ced438bb50e087583ac230de342acf76 Mon Sep 17 00:00:00 2001 From: Mateusz Drewniak Date: Sat, 22 Jul 2023 21:50:19 +0200 Subject: [PATCH 05/16] Improve rerendering on cursor position change --- buffer.go | 34 ++++++++++++++++++++++++++++++++++ emacs_test.go | 11 ++++++----- prompt.go | 42 ++++++++++++++++++++++++++++-------------- 3 files changed, 68 insertions(+), 19 deletions(-) diff --git a/buffer.go b/buffer.go index 42ae5694..4cbbbc5a 100644 --- a/buffer.go +++ b/buffer.go @@ -129,6 +129,40 @@ func (b *Buffer) setDocument(d *Document, columns istrings.Width, rows int) { b.RecalculateStartLine(columns, rows) } +// Move to the left on the current line. +// 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 + return b.RecalculateStartLine(columns, rows) +} + +// Move to the right on the current line. +// 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 + return b.RecalculateStartLine(columns, rows) +} + +// CursorUp move cursor to the previous line. +// (for multi-line edit). +// Returns true when the view should be rerendered. +func (b *Buffer) CursorUp(count int, columns istrings.Width, rows int) bool { + orig := b.Document().CursorPositionCol() + b.cursorPosition += b.Document().GetCursorUpPosition(count, orig) + return b.RecalculateStartLine(columns, rows) +} + +// CursorDown move cursor to the next line. +// (for multi-line edit). +// Returns true when the view should be rerendered. +func (b *Buffer) CursorDown(count int, columns istrings.Width, rows int) bool { + orig := b.Document().CursorPositionCol() + b.cursorPosition += b.Document().GetCursorDownPosition(count, orig) + 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) { debug.Assert(count >= 0, "count should be positive") diff --git a/emacs_test.go b/emacs_test.go index b5c84f57..69adba2d 100644 --- a/emacs_test.go +++ b/emacs_test.go @@ -7,30 +7,31 @@ import ( ) func TestEmacsKeyBindings(t *testing.T) { - buf := NewBuffer() + p := New(NoopExecutor) + buf := p.Buffer buf.InsertTextMoveCursor("abcde", DefColCount, DefRowCount, false) if buf.cursorPosition != istrings.RuneNumber(len("abcde")) { t.Errorf("Want %d, but got %d", len("abcde"), buf.cursorPosition) } // Go to the beginning of the line - applyEmacsKeyBind(buf, ControlA) + applyEmacsKeyBind(p, ControlA) if buf.cursorPosition != 0 { t.Errorf("Want %d, but got %d", 0, buf.cursorPosition) } // Go to the end of the line - applyEmacsKeyBind(buf, ControlE) + applyEmacsKeyBind(p, ControlE) if buf.cursorPosition != istrings.RuneNumber(len("abcde")) { t.Errorf("Want %d, but got %d", len("abcde"), buf.cursorPosition) } } -func applyEmacsKeyBind(buf *Buffer, key Key) { +func applyEmacsKeyBind(p *Prompt, key Key) { for i := range emacsKeyBindings { kb := emacsKeyBindings[i] if kb.Key == key { - kb.Fn(buf, DefColCount, DefRowCount) + kb.Fn(p) } } } diff --git a/prompt.go b/prompt.go index e00fd275..a9c0532e 100644 --- a/prompt.go +++ b/prompt.go @@ -55,6 +55,7 @@ type Prompt struct { exitChecker ExitChecker executeOnEnterCallback ExecuteOnEnterCallback skipClose bool + completionReset bool } // UserInput is the struct that contains the user input context. @@ -236,6 +237,8 @@ func (p *Prompt) feed(b []byte) (shouldExit bool, rerender bool, userInput *User func (p *Prompt) handleCompletionKeyBinding(b []byte, key Key, completing bool) { cols := p.renderer.UserInputColumns() rows := int(p.renderer.row) + completionLen := len(p.completion.tmp) + p.completionReset = false keySwitch: switch key { @@ -250,7 +253,7 @@ keySwitch: p.completion.Previous() } case Tab: - if len(p.completion.GetSuggestions()) > 0 { + if completionLen > 0 { // If there are any suggestions, select the next one p.completion.Next() break @@ -270,7 +273,7 @@ keySwitch: } p.Buffer.InsertTextMoveCursor(string(newBytes), cols, rows, false) case BackTab: - if len(p.completion.GetSuggestions()) > 0 { + if completionLen > 0 { // If there are any suggestions, select the previous one p.completion.Previous() break @@ -291,6 +294,9 @@ keySwitch: } p.Buffer.InsertTextMoveCursor(s.Text, cols, rows, false) } + if completionLen > 0 { + p.completionReset = true + } p.completion.Reset() } } @@ -453,14 +459,16 @@ func (p *Prompt) CursorLeft(count istrings.RuneNumber) bool { cols := p.renderer.UserInputColumns() previousCursor := b.DisplayCursorPosition(cols) - l := b.Document().GetCursorLeftPosition(count) - b.cursorPosition += l + rerender := p.Buffer.CursorLeft(count, cols, p.renderer.row) || p.completionReset + if rerender { + return true + } newCursor := b.DisplayCursorPosition(cols) p.renderer.previousCursor = newCursor p.renderer.move(previousCursor, newCursor) p.renderer.flush() - return b.RecalculateStartLine(cols, p.renderer.row) + return false } // Move the cursor to the right on the current line. @@ -470,14 +478,16 @@ func (p *Prompt) CursorRight(count istrings.RuneNumber) bool { cols := p.renderer.UserInputColumns() previousCursor := b.DisplayCursorPosition(cols) - l := b.Document().GetCursorRightPosition(count) - b.cursorPosition += l + rerender := p.Buffer.CursorRight(count, cols, p.renderer.row) || p.completionReset + if rerender { + return true + } newCursor := b.DisplayCursorPosition(cols) p.renderer.previousCursor = newCursor p.renderer.move(previousCursor, newCursor) p.renderer.flush() - return b.RecalculateStartLine(cols, p.renderer.row) + return false } // Move the cursor up. @@ -487,14 +497,16 @@ func (p *Prompt) CursorUp(count int) bool { cols := p.renderer.UserInputColumns() previousCursor := b.DisplayCursorPosition(cols) - orig := b.Document().CursorPositionCol() - b.cursorPosition += b.Document().GetCursorUpPosition(count, orig) + rerender := p.Buffer.CursorUp(count, cols, p.renderer.row) || p.completionReset + if rerender { + return true + } newCursor := b.DisplayCursorPosition(cols) p.renderer.previousCursor = newCursor p.renderer.move(previousCursor, newCursor) p.renderer.flush() - return b.RecalculateStartLine(cols, p.renderer.row) + return false } // Move the cursor down. @@ -504,14 +516,16 @@ func (p *Prompt) CursorDown(count int) bool { cols := p.renderer.UserInputColumns() previousCursor := b.DisplayCursorPosition(cols) - orig := b.Document().CursorPositionCol() - b.cursorPosition += b.Document().GetCursorDownPosition(count, orig) + rerender := p.Buffer.CursorDown(count, cols, p.renderer.row) || p.completionReset + if rerender { + return true + } newCursor := b.DisplayCursorPosition(cols) p.renderer.previousCursor = newCursor p.renderer.move(previousCursor, newCursor) p.renderer.flush() - return b.RecalculateStartLine(cols, p.renderer.row) + return false } func (p *Prompt) Close() { From e4510ccf21938098f92baca7d74aa559b146b0c8 Mon Sep 17 00:00:00 2001 From: Mateusz Drewniak Date: Sat, 22 Jul 2023 23:07:45 +0200 Subject: [PATCH 06/16] Optimise rendering with a lexer --- _example/even-lexer/main.go | 5 ++++- lexer.go | 22 +++++++++++++--------- renderer.go | 7 ++++--- 3 files changed, 21 insertions(+), 13 deletions(-) diff --git a/_example/even-lexer/main.go b/_example/even-lexer/main.go index 1efc7738..0ca21956 100644 --- a/_example/even-lexer/main.go +++ b/_example/even-lexer/main.go @@ -2,8 +2,10 @@ package main import ( "fmt" + "unicode/utf8" "github.com/elk-language/go-prompt" + "github.com/elk-language/go-prompt/strings" ) func main() { @@ -26,7 +28,8 @@ func lexer(line string) []prompt.Token { } else { color = prompt.White } - element := prompt.NewSimpleToken(color, string(value)) + lastByteIndex := strings.ByteNumber(i + utf8.RuneLen(value) - 1) + element := prompt.NewSimpleToken(color, lastByteIndex) elements = append(elements, element) } diff --git a/lexer.go b/lexer.go index fba81987..038da6bf 100644 --- a/lexer.go +++ b/lexer.go @@ -1,5 +1,9 @@ package prompt +import ( + istrings "github.com/elk-language/go-prompt/strings" +) + // Lexer is a streaming lexer that takes in a piece of text // and streams tokens with the Next() method type Lexer interface { @@ -11,21 +15,21 @@ type Lexer interface { // Token is a single unit of text returned by a Lexer. type Token interface { - Color() Color - Lexeme() string // original string that matches this token + Color() Color // Color of the token + LastByteIndex() istrings.ByteNumber // Index of the last byte of this token } // SimpleToken as the default implementation of Token. type SimpleToken struct { - color Color - lexeme string + color Color + lastByteIndex istrings.ByteNumber } // Create a new SimpleToken. -func NewSimpleToken(color Color, lexeme string) *SimpleToken { +func NewSimpleToken(color Color, index istrings.ByteNumber) *SimpleToken { return &SimpleToken{ - color: color, - lexeme: lexeme, + color: color, + lastByteIndex: index, } } @@ -35,8 +39,8 @@ func (t *SimpleToken) Color() Color { } // Retrieve the text that this token represents. -func (t *SimpleToken) Lexeme() string { - return t.lexeme +func (t *SimpleToken) LastByteIndex() istrings.ByteNumber { + return t.lastByteIndex } // LexerFunc is a function implementing diff --git a/renderer.go b/renderer.go index b04fbd7d..6fb8f477 100644 --- a/renderer.go +++ b/renderer.go @@ -368,7 +368,6 @@ func (r *Renderer) getMultilinePrefix(prefix string) string { func (r *Renderer) lex(lexer Lexer, buffer *Buffer) { input := buffer.Text() lexer.Init(input) - s := input prefix := r.prefixCallback() prefixWidth := istrings.GetWidth(prefix) @@ -378,6 +377,7 @@ func (r *Renderer) lex(lexer Lexer, buffer *Buffer) { var lineCharIndex istrings.Width var lineNumber int endLine := buffer.startLine + int(r.row) + var previousByteIndex istrings.ByteNumber tokenLoop: for { @@ -386,8 +386,9 @@ tokenLoop: break } - text := strings.SplitAfter(s, token.Lexeme())[0] - s = strings.TrimPrefix(s, text) + currentByteIndex := token.LastByteIndex() + text := input[previousByteIndex+1 : currentByteIndex+1] + previousByteIndex = currentByteIndex var lineBuffer strings.Builder From c1c5e342d3e78fc9920f8fcdf3fef1aed17a5387 Mon Sep 17 00:00:00 2001 From: Mateusz Drewniak Date: Sat, 22 Jul 2023 23:12:35 +0200 Subject: [PATCH 07/16] Fix lexer tests --- lexer_test.go | 37 +++++++++++++++++++------------------ 1 file changed, 19 insertions(+), 18 deletions(-) diff --git a/lexer_test.go b/lexer_test.go index b4ccd567..bd3fd632 100644 --- a/lexer_test.go +++ b/lexer_test.go @@ -3,6 +3,7 @@ package prompt import ( "testing" + istrings "github.com/elk-language/go-prompt/strings" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" ) @@ -16,32 +17,32 @@ func TestEagerLexerNext(t *testing.T) { "return the first token when at the beginning": { lexer: &EagerLexer{ tokens: []Token{ - &SimpleToken{lexeme: "foo"}, - &SimpleToken{lexeme: "bar"}, + &SimpleToken{lastByteIndex: 0}, + &SimpleToken{lastByteIndex: 1}, }, currentIndex: 0, }, - want: &SimpleToken{lexeme: "foo"}, + want: &SimpleToken{lastByteIndex: 0}, ok: true, }, "return the second token": { lexer: &EagerLexer{ tokens: []Token{ - &SimpleToken{lexeme: "foo"}, - &SimpleToken{lexeme: "bar"}, - &SimpleToken{lexeme: "baz"}, + &SimpleToken{lastByteIndex: 3}, + &SimpleToken{lastByteIndex: 5}, + &SimpleToken{lastByteIndex: 6}, }, currentIndex: 1, }, - want: &SimpleToken{lexeme: "bar"}, + want: &SimpleToken{lastByteIndex: 5}, ok: true, }, "return false when at the end": { lexer: &EagerLexer{ tokens: []Token{ - &SimpleToken{lexeme: "foo"}, - &SimpleToken{lexeme: "bar"}, - &SimpleToken{lexeme: "baz"}, + &SimpleToken{lastByteIndex: 0}, + &SimpleToken{lastByteIndex: 4}, + &SimpleToken{lastByteIndex: 5}, }, currentIndex: 3, }, @@ -68,8 +69,8 @@ func TestEagerLexerNext(t *testing.T) { func charLex(s string) []Token { var result []Token - for _, char := range s { - result = append(result, NewSimpleToken(0, string(char))) + for i, _ := range s { + result = append(result, NewSimpleToken(0, istrings.ByteNumber(i))) } return result @@ -85,18 +86,18 @@ func TestEagerLexerInit(t *testing.T) { lexer: &EagerLexer{ lexFunc: charLex, tokens: []Token{ - &SimpleToken{lexeme: "foo"}, - &SimpleToken{lexeme: "bar"}, + &SimpleToken{lastByteIndex: 2}, + &SimpleToken{lastByteIndex: 10}, }, - currentIndex: 2, + currentIndex: 11, }, input: "foo", want: &EagerLexer{ lexFunc: charLex, tokens: []Token{ - &SimpleToken{lexeme: "f"}, - &SimpleToken{lexeme: "o"}, - &SimpleToken{lexeme: "o"}, + &SimpleToken{lastByteIndex: 0}, + &SimpleToken{lastByteIndex: 1}, + &SimpleToken{lastByteIndex: 2}, }, currentIndex: 0, }, From b4cf4c54cea192621173232327faca8ffbf9ce25 Mon Sep 17 00:00:00 2001 From: Mateusz Drewniak Date: Sun, 23 Jul 2023 00:01:41 +0200 Subject: [PATCH 08/16] Avoid unnecessary string allocations --- renderer.go | 26 ++++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/renderer.go b/renderer.go index 6fb8f477..d9ec38d6 100644 --- a/renderer.go +++ b/renderer.go @@ -328,6 +328,13 @@ func (r *Renderer) writeString(text string, color Color) { } } +func (r *Renderer) write(b []byte, color Color) { + r.out.SetColor(color, r.inputBGColor, false) + if _, err := r.out.Write(b); err != nil { + panic(err) + } +} + func (r *Renderer) getMultilinePrefix(prefix string) string { var spaceCount int var dotCount int @@ -378,6 +385,8 @@ func (r *Renderer) lex(lexer Lexer, buffer *Buffer) { var lineNumber int endLine := buffer.startLine + int(r.row) var previousByteIndex istrings.ByteNumber + lineBuffer := make([]byte, 8) + runeBuffer := make([]byte, utf8.UTFMax) tokenLoop: for { @@ -389,8 +398,7 @@ tokenLoop: currentByteIndex := token.LastByteIndex() text := input[previousByteIndex+1 : currentByteIndex+1] previousByteIndex = currentByteIndex - - var lineBuffer strings.Builder + lineBuffer = lineBuffer[:0] charLoop: for _, char := range text { @@ -402,13 +410,14 @@ tokenLoop: if lineNumber >= endLine { break tokenLoop } - lineBuffer.WriteByte('\n') - r.writeString(lineBuffer.String(), token.Color()) + lineBuffer = append(lineBuffer, '\n') + r.write(lineBuffer, token.Color()) r.renderPrefix(multilinePrefix) lineCharIndex = 0 - lineBuffer.Reset() + lineBuffer = lineBuffer[:0] if char != '\n' { - lineBuffer.WriteRune(char) + size := utf8.EncodeRune(runeBuffer, char) + lineBuffer = append(lineBuffer, runeBuffer[:size]...) lineCharIndex += istrings.GetRuneWidth(char) } continue charLoop @@ -417,10 +426,11 @@ tokenLoop: if lineNumber < buffer.startLine { continue charLoop } - lineBuffer.WriteRune(char) + size := utf8.EncodeRune(runeBuffer, char) + lineBuffer = append(lineBuffer, runeBuffer[:size]...) lineCharIndex += istrings.GetRuneWidth(char) } - r.writeString(lineBuffer.String(), token.Color()) + r.write(lineBuffer, token.Color()) } } From 9c4686947077404da151a18475c7f37bdbee3b04 Mon Sep 17 00:00:00 2001 From: Mateusz Drewniak Date: Sun, 23 Jul 2023 11:31:26 +0200 Subject: [PATCH 09/16] Improve performance by only lexing the visible part of text --- position.go | 85 +++++++++++++++++++++++++++++++++++++++++++++--- position_test.go | 83 ++++++++++++++++++++++++++++++++++++++++++++++ prompt.go | 19 +++++------ renderer.go | 8 +++-- 4 files changed, 177 insertions(+), 18 deletions(-) diff --git a/position.go b/position.go index 787d41c7..97187261 100644 --- a/position.go +++ b/position.go @@ -3,6 +3,8 @@ package prompt import ( "io" "strings" + "unicode" + "unicode/utf8" istrings "github.com/elk-language/go-prompt/strings" "github.com/mattn/go-runewidth" @@ -48,15 +50,90 @@ 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 { - pos := positionAtEndOfReaderLine(strings.NewReader(str), columns, line) - return pos + 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 { - pos := positionAtEndOfReader(strings.NewReader(str), columns) - return pos + return positionAtEndOfReader(strings.NewReader(str), columns) +} + +// Returns the index of the first character on the specified line (terminal row). +// If the line wraps because its contents are longer than the current columns in the terminal +// then the index of the first character of the first word of the specified line gets returned +// (the word may begin the line before or a few lines before and it's taken into consideration). +// +// The unique behaviour is intentional, this function has been designed for use in lexing. +// In order to improve performance the lexer only receives the visible part of the text. +// But the tokens could be incorrect if a token spanning multiple lines (because of wrapping) +// gets divided. This functions is meant to alleviate this effect. +func indexOfFirstTokenOnLine(input string, columns istrings.Width, line int) istrings.ByteNumber { + if len(input) == 0 || line == 0 { + return 0 + } + + str := input + var indexOfWord istrings.ByteNumber + var lastCharSize istrings.ByteNumber + var down int + var right istrings.Width + var i istrings.ByteNumber + +charLoop: + for { + char, size := utf8.DecodeRuneInString(str) + i += lastCharSize + if size == 0 { + break charLoop + } + str = str[size:] + lastCharSize = istrings.ByteNumber(size) + + switch char { + case '\r': + char, size := utf8.DecodeRuneInString(str) + i += lastCharSize + if size == 0 { + break charLoop + } + str = str[size:] + lastCharSize = istrings.ByteNumber(size) + + if char == '\n' { + down++ + right = 0 + indexOfWord = i + 1 + if down >= line { + break charLoop + } + } + case '\n': + down++ + right = 0 + indexOfWord = i + 1 + if down >= line { + break charLoop + } + default: + right += istrings.Width(runewidth.RuneWidth(char)) + if right > columns { + right = istrings.Width(runewidth.RuneWidth(char)) + down++ + if down >= line { + break charLoop + } + } + if unicode.IsSpace(char) { + indexOfWord = i + 1 + } + } + } + + if indexOfWord >= istrings.ByteNumber(len(input)) { + return istrings.ByteNumber(len(input)) - 1 + } + return indexOfWord } // positionAtEndOfReader calculates the position diff --git a/position_test.go b/position_test.go index 0ea6dd31..5263107e 100644 --- a/position_test.go +++ b/position_test.go @@ -129,6 +129,89 @@ baz`, } } +func TestIndexOfFirstTokenOnLine(t *testing.T) { + tests := map[string]struct { + input string + cols istrings.Width + line int + want istrings.ByteNumber + }{ + "line is zero": { + input: ` hi +foobar +baz`, + cols: 20, + line: 0, + want: 0, + }, + "input is empty": { + input: "", + cols: 3, + line: 10, + want: 0, + }, + "word spans two lines": { + input: `hi +foobar +baz`, + cols: 3, + line: 2, + want: 3, + }, + "word spans two lines and line has two words": { + input: `hi +baz foobar +baz`, + cols: 6, + line: 2, + want: 7, + }, + "word spans one line": { + input: `hi +foo +bar +baz`, + cols: 3, + line: 2, + want: 7, + }, + "word is indented and spans two lines": { + input: `hi +foo + barbaz +foo`, + cols: 5, + line: 3, + want: 8, + }, + "word is indented": { + input: `hi +foo + barbaz +foo`, + cols: 8, + line: 2, + want: 7, + }, + "input has fewer lines": { + input: `hi +foo`, + cols: 3, + line: 10, + want: 3, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + got := indexOfFirstTokenOnLine(tc.input, tc.cols, tc.line) + if diff := cmp.Diff(tc.want, got); diff != "" { + t.Fatalf(diff) + } + }) + } +} + func TestPositionAdd(t *testing.T) { tests := map[string]struct { left Position diff --git a/prompt.go b/prompt.go index a9c0532e..6017ceb3 100644 --- a/prompt.go +++ b/prompt.go @@ -2,8 +2,6 @@ package prompt import ( "bytes" - "fmt" - "log" "os" "strings" "time" @@ -117,7 +115,6 @@ func (p *Prompt) Run() { go p.readBuffer(bufCh, stopReadBufCh) go p.handleSignals(exitCh, winSizeCh, stopHandleSignalCh) } else if rerender { - Log("rerender") p.completion.Update(*p.Buffer.Document()) p.renderer.Render(p.Buffer, p.completion, p.lexer) } @@ -136,14 +133,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) diff --git a/renderer.go b/renderer.go index d9ec38d6..8a4a3be5 100644 --- a/renderer.go +++ b/renderer.go @@ -374,7 +374,6 @@ func (r *Renderer) getMultilinePrefix(prefix string) string { // and writes the result func (r *Renderer) lex(lexer Lexer, buffer *Buffer) { input := buffer.Text() - lexer.Init(input) prefix := r.prefixCallback() prefixWidth := istrings.GetWidth(prefix) @@ -384,10 +383,13 @@ func (r *Renderer) lex(lexer Lexer, buffer *Buffer) { var lineCharIndex istrings.Width var lineNumber int endLine := buffer.startLine + int(r.row) - var previousByteIndex istrings.ByteNumber + previousByteIndex := istrings.ByteNumber(-1) lineBuffer := make([]byte, 8) runeBuffer := make([]byte, utf8.UTFMax) + startIndex := indexOfFirstTokenOnLine(input, col, buffer.startLine) + lexer.Init(input[startIndex:]) + tokenLoop: for { token, ok := lexer.Next() @@ -395,7 +397,7 @@ tokenLoop: break } - currentByteIndex := token.LastByteIndex() + currentByteIndex := startIndex + token.LastByteIndex() text := input[previousByteIndex+1 : currentByteIndex+1] previousByteIndex = currentByteIndex lineBuffer = lineBuffer[:0] From ebde4cb5ba0a9e6bcdf10cc5a9ec9cc0a19c028e Mon Sep 17 00:00:00 2001 From: Mateusz Drewniak Date: Sun, 23 Jul 2023 12:30:39 +0200 Subject: [PATCH 10/16] bump --- completion.go | 3 ++- renderer.go | 44 ++++++++++++++++---------------------------- 2 files changed, 18 insertions(+), 29 deletions(-) diff --git a/completion.go b/completion.go index 1eabd56a..fc5fb5d0 100644 --- a/completion.go +++ b/completion.go @@ -81,12 +81,13 @@ func (c *CompletionManager) Previous() { } // Next to select the next suggestion item. -func (c *CompletionManager) Next() { +func (c *CompletionManager) Next() int { if c.verticalScroll+int(c.max)-1 == c.selected { c.verticalScroll++ } c.selected++ c.update() + return c.selected } // Completing returns true when the CompletionManager selects something. diff --git a/renderer.go b/renderer.go index 8a4a3be5..ddc9ac73 100644 --- a/renderer.go +++ b/renderer.go @@ -10,7 +10,7 @@ import ( const multilinePrefixCharacter = '.' -// Renderer to render prompt information from state of Buffer. +// Takes care of the rendering process type Renderer struct { out Writer prefixCallback PrefixCallback @@ -88,7 +88,7 @@ func (r *Renderer) renderPrefix(prefix string) { r.out.SetColor(DefaultColor, DefaultColor, false) } -// Close to clear title and erasing. +// Close to clear title and erase. func (r *Renderer) Close() { r.out.ClearTitle() r.out.EraseDown() @@ -110,15 +110,6 @@ func (r *Renderer) UpdateWinSize(ws *WinSize) { r.col = istrings.Width(ws.Col) } -func (r *Renderer) renderWindowTooSmall() { - r.out.CursorGoTo(0, 0) - r.out.EraseScreen() - r.out.SetColor(DarkRed, White, false) - if _, err := r.out.WriteString("Your console window is too small..."); err != nil { - panic(err) - } -} - func (r *Renderer) renderCompletion(buf *Buffer, completions *CompletionManager) { suggestions := completions.GetSuggestions() if len(suggestions) == 0 { @@ -227,7 +218,7 @@ func (r *Renderer) Render(buffer *Buffer, completion *CompletionManager, lexer L r.out.HideCursor() defer r.out.ShowCursor() - r.renderText(lexer, buffer) + r.renderText(lexer, buffer.Text(), buffer.startLine) r.out.SetColor(DefaultColor, DefaultColor, false) @@ -250,7 +241,7 @@ func (r *Renderer) Render(buffer *Buffer, completion *CompletionManager, lexer L rest := buffer.Document().TextAfterCursor() - r.renderText(lexer, buffer) + r.renderText(lexer, buffer.Text(), buffer.startLine) r.out.SetColor(DefaultColor, DefaultColor, false) @@ -261,27 +252,26 @@ func (r *Renderer) Render(buffer *Buffer, completion *CompletionManager, lexer L r.previousCursor = cursor } -func (r *Renderer) renderText(lexer Lexer, buffer *Buffer) { +func (r *Renderer) renderText(lexer Lexer, input string, startLine int) { if lexer != nil { - r.lex(lexer, buffer) + r.lex(lexer, input, startLine) return } - text := buffer.Text() prefix := r.prefixCallback() prefixWidth := istrings.GetWidth(prefix) col := r.col - prefixWidth multilinePrefix := r.getMultilinePrefix(prefix) firstIteration := true - endLine := buffer.startLine + int(r.row) + endLine := startLine + int(r.row) var lineBuffer strings.Builder var lineCharIndex istrings.Width var lineNumber int - for _, char := range text { + for _, char := range input { if lineCharIndex >= col || char == '\n' { lineNumber++ - if lineNumber < buffer.startLine { + if lineNumber < startLine { continue } if lineNumber >= endLine { @@ -302,7 +292,7 @@ func (r *Renderer) renderText(lexer Lexer, buffer *Buffer) { continue } - if lineNumber < buffer.startLine { + if lineNumber < startLine { continue } lineBuffer.WriteRune(char) @@ -372,9 +362,7 @@ func (r *Renderer) getMultilinePrefix(prefix string) string { // lex processes the given input with the given lexer // and writes the result -func (r *Renderer) lex(lexer Lexer, buffer *Buffer) { - input := buffer.Text() - +func (r *Renderer) lex(lexer Lexer, input string, startLine int) { prefix := r.prefixCallback() prefixWidth := istrings.GetWidth(prefix) col := r.col - prefixWidth @@ -382,12 +370,12 @@ func (r *Renderer) lex(lexer Lexer, buffer *Buffer) { r.renderPrefix(prefix) var lineCharIndex istrings.Width var lineNumber int - endLine := buffer.startLine + int(r.row) + endLine := startLine + int(r.row) previousByteIndex := istrings.ByteNumber(-1) lineBuffer := make([]byte, 8) runeBuffer := make([]byte, utf8.UTFMax) - startIndex := indexOfFirstTokenOnLine(input, col, buffer.startLine) + startIndex := indexOfFirstTokenOnLine(input, col, startLine) lexer.Init(input[startIndex:]) tokenLoop: @@ -406,7 +394,7 @@ tokenLoop: for _, char := range text { if lineCharIndex >= col || char == '\n' { lineNumber++ - if lineNumber < buffer.startLine { + if lineNumber < startLine { continue charLoop } if lineNumber >= endLine { @@ -425,7 +413,7 @@ tokenLoop: continue charLoop } - if lineNumber < buffer.startLine { + if lineNumber < startLine { continue charLoop } size := utf8.EncodeRune(runeBuffer, char) @@ -445,7 +433,7 @@ func (r *Renderer) BreakLine(buffer *Buffer, lexer Lexer) { cursor.X += prefixWidth r.clear(cursor) - r.renderText(lexer, buffer) + r.renderText(lexer, buffer.Text(), buffer.startLine) if _, err := r.out.WriteString("\n"); err != nil { panic(err) } From 5b3ff9a017cafbd8c58e1db4c20a3b59de19ac24 Mon Sep 17 00:00:00 2001 From: Mateusz Drewniak Date: Sun, 23 Jul 2023 14:15:04 +0200 Subject: [PATCH 11/16] Fix completion preview rendering --- CHANGELOG.md | 4 +- _example/http-prompt/main.go | 10 ++-- completion.go | 23 ++++----- completion_test.go | 3 +- constructor.go | 16 ------ document.go | 5 ++ prompt.go | 95 ++++++++++++++++++++++++++++++------ renderer.go | 25 ---------- 8 files changed, 105 insertions(+), 76 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e35e0c3..2c370e03 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -55,7 +55,6 @@ This release aims to make the code a bit cleaner, fix a couple of bugs and provi - Rename `prompt.OptionPrefixBackgroundColor` to `prompt.WithPrefixBackgroundColor` - Rename `prompt.OptionInputTextColor` to `prompt.WithInputTextColor` - Rename `prompt.OptionInputBGColor` to `prompt.WithInputBGColor` -- Rename `prompt.OptionPreviewSuggestionTextColor` to `prompt.WithPreviewSuggestionTextColor` - Rename `prompt.OptionSuggestionTextColor` to `prompt.WithSuggestionTextColor` - Rename `prompt.OptionSuggestionBGColor` to `prompt.WithSuggestionBGColor` - Rename `prompt.OptionSelectedSuggestionTextColor` to `prompt.WithSelectedSuggestionTextColor` @@ -86,7 +85,8 @@ This release aims to make the code a bit cleaner, fix a couple of bugs and provi ### Removed - `prompt.SwitchKeyBindMode` - +- `prompt.OptionPreviewSuggestionTextColor` +- `prompt.OptionPreviewSuggestionBGColor` ## [0.2.6] - 2021-03-03 diff --git a/_example/http-prompt/main.go b/_example/http-prompt/main.go index d80e0a25..b77825db 100644 --- a/_example/http-prompt/main.go +++ b/_example/http-prompt/main.go @@ -11,6 +11,7 @@ import ( "strings" prompt "github.com/elk-language/go-prompt" + istrings "github.com/elk-language/go-prompt/strings" ) type RequestContext struct { @@ -157,12 +158,15 @@ func executor(in string) { } } -func completer(in prompt.Document) []prompt.Suggest { +func completer(in prompt.Document) ([]prompt.Suggest, istrings.RuneNumber, istrings.RuneNumber) { + currentIndex := in.CurrentRuneIndex() w := in.GetWordBeforeCursor() if w == "" { - return []prompt.Suggest{} + return []prompt.Suggest{}, 0, 0 } - return prompt.FilterHasPrefix(suggestions, w, true) + startIndex := currentIndex - istrings.RuneCount(w) + endIndex := currentIndex + return prompt.FilterHasPrefix(suggestions, w, true), startIndex, endIndex } func main() { diff --git a/completion.go b/completion.go index fc5fb5d0..35fea01b 100644 --- a/completion.go +++ b/completion.go @@ -16,12 +16,6 @@ const ( rightSuffix = " " ) -var ( - leftMargin = istrings.GetWidth(leftPrefix + leftSuffix) - rightMargin = istrings.GetWidth(rightPrefix + rightSuffix) - completionMargin = leftMargin + rightMargin -) - // Suggest represents a single suggestion // in the auto-complete box. type Suggest struct { @@ -31,10 +25,13 @@ type Suggest struct { // CompletionManager manages which suggestion is now selected. type CompletionManager struct { - selected int // -1 means nothing is selected. - tmp []Suggest - max uint16 - completer Completer + selected int // -1 means nothing is selected. + tmp []Suggest + max uint16 + completer Completer + startCharIndex istrings.RuneNumber // index of the first char of the text that should be replaced by the selected suggestion + endCharIndex istrings.RuneNumber // index of the last char of the text that should be replaced by the selected suggestion + shouldUpdate bool verticalScroll int wordSeparator string @@ -68,7 +65,7 @@ func (c *CompletionManager) Reset() { // Update the suggestions. func (c *CompletionManager) Update(in Document) { - c.tmp = c.completer(in) + c.tmp, c.startCharIndex, c.endCharIndex = c.completer(in) } // Select the previous suggestion item. @@ -212,6 +209,6 @@ var _ Completer = NoopCompleter // NoopCompleter implements a Completer function // that always returns no suggestions. -func NoopCompleter(_ Document) []Suggest { - return nil +func NoopCompleter(_ Document) ([]Suggest, istrings.RuneNumber, istrings.RuneNumber) { + return nil, 0, 0 } diff --git a/completion_test.go b/completion_test.go index 959d86f1..11bc66f2 100644 --- a/completion_test.go +++ b/completion_test.go @@ -208,7 +208,8 @@ func TestFormatText(t *testing.T) { } func TestNoopCompleter(t *testing.T) { - if NoopCompleter(Document{}) != nil { + sug, start, end := NoopCompleter(Document{}) + if sug != nil || start != 0 || end != 0 { t.Errorf("NoopCompleter should return nil") } } diff --git a/constructor.go b/constructor.go index 2da19034..f6d32c6d 100644 --- a/constructor.go +++ b/constructor.go @@ -133,22 +133,6 @@ func WithInputBGColor(x Color) Option { } } -// WithPreviewSuggestionTextColor to change a text color which is completed -func WithPreviewSuggestionTextColor(x Color) Option { - return func(p *Prompt) error { - p.renderer.previewSuggestionTextColor = x - return nil - } -} - -// WithPreviewSuggestionBGColor to change a background color which is completed -func WithPreviewSuggestionBGColor(x Color) Option { - return func(p *Prompt) error { - p.renderer.previewSuggestionBGColor = x - return nil - } -} - // WithSuggestionTextColor to change a text color in drop down suggestions. func WithSuggestionTextColor(x Color) Option { return func(p *Prompt) error { diff --git a/document.go b/document.go index 7cb0f213..a3ae1ff8 100644 --- a/document.go +++ b/document.go @@ -56,6 +56,11 @@ func (d *Document) GetCharRelativeToCursor(offset istrings.RuneNumber) (r rune) return 0 } +// Returns the index of the rune that's under the cursor. +func (d *Document) CurrentRuneIndex() istrings.RuneNumber { + return d.cursorPosition +} + // TextBeforeCursor returns the text before the cursor. func (d *Document) TextBeforeCursor() string { r := []rune(d.Text) diff --git a/prompt.go b/prompt.go index 6017ceb3..8de1517c 100644 --- a/prompt.go +++ b/prompt.go @@ -2,6 +2,8 @@ package prompt import ( "bytes" + "fmt" + "log" "os" "strings" "time" @@ -35,7 +37,10 @@ type ExecuteOnEnterCallback func(input string, indentSize int) (indent int, exec // Completer is a function that returns // a slice of suggestions for the given Document. -type Completer func(Document) []Suggest +// +// startChar and endChar represent the indices of the first and last rune of the text +// that the suggestions were generated for and that should be replaced by the selected suggestion. +type Completer func(Document) (suggestions []Suggest, startChar, endChar istrings.RuneNumber) // Prompt is a core struct of go-prompt. type Prompt struct { @@ -115,7 +120,9 @@ func (p *Prompt) Run() { go p.readBuffer(bufCh, stopReadBufCh) go p.handleSignals(exitCh, winSizeCh, stopHandleSignalCh) } else if rerender { - p.completion.Update(*p.Buffer.Document()) + if p.completion.shouldUpdate { + p.completion.Update(*p.Buffer.Document()) + } p.renderer.Render(p.Buffer, p.completion, p.lexer) } case w := <-winSizeCh: @@ -133,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) @@ -232,8 +239,9 @@ func (p *Prompt) feed(b []byte) (shouldExit bool, rerender bool, userInput *User } func (p *Prompt) handleCompletionKeyBinding(b []byte, key Key, completing bool) { + p.completion.shouldUpdate = true cols := p.renderer.UserInputColumns() - rows := int(p.renderer.row) + rows := p.renderer.row completionLen := len(p.completion.tmp) p.completionReset = false @@ -241,18 +249,27 @@ keySwitch: switch key { case Down: if completing || p.completionOnDown { - p.completion.Next() + p.updateSuggestions(func() { + p.completion.Next() + }) } case ControlI: - p.completion.Next() + p.updateSuggestions(func() { + p.completion.Next() + }) case Up: if completing { - p.completion.Previous() + p.updateSuggestions(func() { + p.completion.Previous() + }) } case Tab: if completionLen > 0 { // If there are any suggestions, select the next one - p.completion.Next() + p.updateSuggestions(func() { + p.completion.Next() + }) + break } @@ -272,7 +289,9 @@ keySwitch: case BackTab: if completionLen > 0 { // If there are any suggestions, select the previous one - p.completion.Previous() + p.updateSuggestions(func() { + p.completion.Previous() + }) break } @@ -298,6 +317,50 @@ keySwitch: } } +func (p *Prompt) updateSuggestions(fn func()) { + cols := p.renderer.UserInputColumns() + rows := p.renderer.row + + prevStart := p.completion.startCharIndex + prevEnd := p.completion.endCharIndex + prevSuggestion, prevSelected := p.completion.GetSelectedSuggestion() + + fn() + + p.completion.shouldUpdate = false + newSuggestion, newSelected := p.completion.GetSelectedSuggestion() + + // do nothing + if !prevSelected && !newSelected { + return + } + + // insert the new selection + if !prevSelected { + p.Buffer.DeleteBeforeCursor(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( + istrings.RuneCount(prevSuggestion.Text)-(prevEnd-prevStart), + cols, + rows, + ) + return + } + + // delete previous selection and render the new one + p.Buffer.DeleteBeforeCursor( + istrings.RuneCount(prevSuggestion.Text), + cols, + rows, + ) + + p.Buffer.InsertTextMoveCursor(newSuggestion.Text, cols, rows, false) +} + func (p *Prompt) handleKeyBinding(key Key, cols istrings.Width, rows int) (shouldExit bool, rerender bool) { var executed bool for i := range commonKeyBindings { diff --git a/renderer.go b/renderer.go index ddc9ac73..eaefafb2 100644 --- a/renderer.go +++ b/renderer.go @@ -27,8 +27,6 @@ type Renderer struct { prefixBGColor Color inputTextColor Color inputBGColor Color - previewSuggestionTextColor Color - previewSuggestionBGColor Color suggestionTextColor Color suggestionBGColor Color selectedSuggestionTextColor Color @@ -54,8 +52,6 @@ func NewRenderer() *Renderer { prefixBGColor: DefaultColor, inputTextColor: DefaultColor, inputBGColor: DefaultColor, - previewSuggestionTextColor: Green, - previewSuggestionBGColor: DefaultColor, suggestionTextColor: White, suggestionBGColor: Cyan, selectedSuggestionTextColor: Black, @@ -228,27 +224,6 @@ func (r *Renderer) Render(buffer *Buffer, completion *CompletionManager, lexer L cursor = r.move(cursor, targetCursor) r.renderCompletion(buffer, completion) - if suggest, ok := completion.GetSelectedSuggestion(); ok { - cursor = r.backward(cursor, istrings.GetWidth(buffer.Document().GetWordBeforeCursorUntilSeparator(completion.wordSeparator))) - - r.out.SetColor(r.previewSuggestionTextColor, r.previewSuggestionBGColor, false) - if _, err := r.out.WriteString(suggest.Text); err != nil { - panic(err) - } - r.out.SetColor(DefaultColor, DefaultColor, false) - cursor.X += istrings.GetWidth(suggest.Text) - endOfSuggestionPos := cursor - - rest := buffer.Document().TextAfterCursor() - - r.renderText(lexer, buffer.Text(), buffer.startLine) - - r.out.SetColor(DefaultColor, DefaultColor, false) - - cursor = cursor.Join(positionAtEndOfString(rest, col)) - - cursor = r.move(cursor, endOfSuggestionPos) - } r.previousCursor = cursor } From e1d0c6fef99acbbb0311cbb2a782be9ee9b2c9bd Mon Sep 17 00:00:00 2001 From: Mateusz Drewniak Date: Sun, 23 Jul 2023 16:54:16 +0200 Subject: [PATCH 12/16] Stop rendering one line more than necessary --- position.go | 79 --------------------------------------------- position_test.go | 83 ------------------------------------------------ renderer.go | 32 ++++++++++++------- writer_vt100.go | 7 ++-- 4 files changed, 24 insertions(+), 177 deletions(-) diff --git a/position.go b/position.go index 97187261..138e9243 100644 --- a/position.go +++ b/position.go @@ -3,8 +3,6 @@ package prompt import ( "io" "strings" - "unicode" - "unicode/utf8" istrings "github.com/elk-language/go-prompt/strings" "github.com/mattn/go-runewidth" @@ -59,83 +57,6 @@ func positionAtEndOfString(str string, columns istrings.Width) Position { return positionAtEndOfReader(strings.NewReader(str), columns) } -// Returns the index of the first character on the specified line (terminal row). -// If the line wraps because its contents are longer than the current columns in the terminal -// then the index of the first character of the first word of the specified line gets returned -// (the word may begin the line before or a few lines before and it's taken into consideration). -// -// The unique behaviour is intentional, this function has been designed for use in lexing. -// In order to improve performance the lexer only receives the visible part of the text. -// But the tokens could be incorrect if a token spanning multiple lines (because of wrapping) -// gets divided. This functions is meant to alleviate this effect. -func indexOfFirstTokenOnLine(input string, columns istrings.Width, line int) istrings.ByteNumber { - if len(input) == 0 || line == 0 { - return 0 - } - - str := input - var indexOfWord istrings.ByteNumber - var lastCharSize istrings.ByteNumber - var down int - var right istrings.Width - var i istrings.ByteNumber - -charLoop: - for { - char, size := utf8.DecodeRuneInString(str) - i += lastCharSize - if size == 0 { - break charLoop - } - str = str[size:] - lastCharSize = istrings.ByteNumber(size) - - switch char { - case '\r': - char, size := utf8.DecodeRuneInString(str) - i += lastCharSize - if size == 0 { - break charLoop - } - str = str[size:] - lastCharSize = istrings.ByteNumber(size) - - if char == '\n' { - down++ - right = 0 - indexOfWord = i + 1 - if down >= line { - break charLoop - } - } - case '\n': - down++ - right = 0 - indexOfWord = i + 1 - if down >= line { - break charLoop - } - default: - right += istrings.Width(runewidth.RuneWidth(char)) - if right > columns { - right = istrings.Width(runewidth.RuneWidth(char)) - down++ - if down >= line { - break charLoop - } - } - if unicode.IsSpace(char) { - indexOfWord = i + 1 - } - } - } - - if indexOfWord >= istrings.ByteNumber(len(input)) { - return istrings.ByteNumber(len(input)) - 1 - } - return indexOfWord -} - // positionAtEndOfReader calculates the position // at the end of the given io.Reader. func positionAtEndOfReader(reader io.RuneReader, columns istrings.Width) Position { diff --git a/position_test.go b/position_test.go index 5263107e..0ea6dd31 100644 --- a/position_test.go +++ b/position_test.go @@ -129,89 +129,6 @@ baz`, } } -func TestIndexOfFirstTokenOnLine(t *testing.T) { - tests := map[string]struct { - input string - cols istrings.Width - line int - want istrings.ByteNumber - }{ - "line is zero": { - input: ` hi -foobar -baz`, - cols: 20, - line: 0, - want: 0, - }, - "input is empty": { - input: "", - cols: 3, - line: 10, - want: 0, - }, - "word spans two lines": { - input: `hi -foobar -baz`, - cols: 3, - line: 2, - want: 3, - }, - "word spans two lines and line has two words": { - input: `hi -baz foobar -baz`, - cols: 6, - line: 2, - want: 7, - }, - "word spans one line": { - input: `hi -foo -bar -baz`, - cols: 3, - line: 2, - want: 7, - }, - "word is indented and spans two lines": { - input: `hi -foo - barbaz -foo`, - cols: 5, - line: 3, - want: 8, - }, - "word is indented": { - input: `hi -foo - barbaz -foo`, - cols: 8, - line: 2, - want: 7, - }, - "input has fewer lines": { - input: `hi -foo`, - cols: 3, - line: 10, - want: 3, - }, - } - - for name, tc := range tests { - t.Run(name, func(t *testing.T) { - got := indexOfFirstTokenOnLine(tc.input, tc.cols, tc.line) - if diff := cmp.Diff(tc.want, got); diff != "" { - t.Fatalf(diff) - } - }) - } -} - func TestPositionAdd(t *testing.T) { tests := map[string]struct { left Position diff --git a/renderer.go b/renderer.go index eaefafb2..94cb3e5c 100644 --- a/renderer.go +++ b/renderer.go @@ -237,6 +237,9 @@ func (r *Renderer) renderText(lexer Lexer, input string, startLine int) { prefixWidth := istrings.GetWidth(prefix) col := r.col - prefixWidth multilinePrefix := r.getMultilinePrefix(prefix) + if startLine != 0 { + prefix = multilinePrefix + } firstIteration := true endLine := startLine + int(r.row) var lineBuffer strings.Builder @@ -246,7 +249,7 @@ func (r *Renderer) renderText(lexer Lexer, input string, startLine int) { for _, char := range input { if lineCharIndex >= col || char == '\n' { lineNumber++ - if lineNumber < startLine { + if lineNumber-1 < startLine { continue } if lineNumber >= endLine { @@ -283,17 +286,17 @@ func (r *Renderer) flush() { func (r *Renderer) renderLine(prefix, line string, color Color) { r.renderPrefix(prefix) - r.writeString(line, color) + r.writeStringColor(line, color) } -func (r *Renderer) writeString(text string, color Color) { +func (r *Renderer) writeStringColor(text string, color Color) { r.out.SetColor(color, r.inputBGColor, false) if _, err := r.out.WriteString(text); err != nil { panic(err) } } -func (r *Renderer) write(b []byte, color Color) { +func (r *Renderer) writeColor(b []byte, color Color) { r.out.SetColor(color, r.inputBGColor, false) if _, err := r.out.Write(b); err != nil { panic(err) @@ -342,7 +345,6 @@ func (r *Renderer) lex(lexer Lexer, input string, startLine int) { prefixWidth := istrings.GetWidth(prefix) col := r.col - prefixWidth multilinePrefix := r.getMultilinePrefix(prefix) - r.renderPrefix(prefix) var lineCharIndex istrings.Width var lineNumber int endLine := startLine + int(r.row) @@ -350,17 +352,21 @@ func (r *Renderer) lex(lexer Lexer, input string, startLine int) { lineBuffer := make([]byte, 8) runeBuffer := make([]byte, utf8.UTFMax) - startIndex := indexOfFirstTokenOnLine(input, col, startLine) - lexer.Init(input[startIndex:]) + lexer.Init(input) + + if startLine != 0 { + prefix = multilinePrefix + } + r.renderPrefix(prefix) tokenLoop: for { token, ok := lexer.Next() if !ok { - break + break tokenLoop } - currentByteIndex := startIndex + token.LastByteIndex() + currentByteIndex := token.LastByteIndex() text := input[previousByteIndex+1 : currentByteIndex+1] previousByteIndex = currentByteIndex lineBuffer = lineBuffer[:0] @@ -369,14 +375,14 @@ tokenLoop: for _, char := range text { if lineCharIndex >= col || char == '\n' { lineNumber++ - if lineNumber < startLine { + if lineNumber-1 < startLine { continue charLoop } if lineNumber >= endLine { break tokenLoop } lineBuffer = append(lineBuffer, '\n') - r.write(lineBuffer, token.Color()) + r.writeColor(lineBuffer, token.Color()) r.renderPrefix(multilinePrefix) lineCharIndex = 0 lineBuffer = lineBuffer[:0] @@ -395,7 +401,9 @@ tokenLoop: lineBuffer = append(lineBuffer, runeBuffer[:size]...) lineCharIndex += istrings.GetRuneWidth(char) } - r.write(lineBuffer, token.Color()) + if len(lineBuffer) > 0 { + r.writeColor(lineBuffer, token.Color()) + } } } diff --git a/writer_vt100.go b/writer_vt100.go index 7da31c21..f884cd64 100644 --- a/writer_vt100.go +++ b/writer_vt100.go @@ -224,19 +224,20 @@ func (w *VT100Writer) SetColor(fg, bg Color, bold bool) { } } +const formatSeparator = ';' + // SetDisplayAttributes to set VT100 display attributes. func (w *VT100Writer) SetDisplayAttributes(fg, bg Color, attrs ...DisplayAttribute) { w.WriteRaw([]byte{0x1b, '['}) // control sequence introducer defer w.WriteRaw([]byte{'m'}) // final character - var separator byte = ';' for i := range attrs { p, ok := displayAttributeParameters[attrs[i]] if !ok { continue } w.WriteRaw(p) - w.WriteRaw([]byte{separator}) + w.WriteRaw([]byte{formatSeparator}) } f, ok := foregroundANSIColors[fg] @@ -244,7 +245,7 @@ func (w *VT100Writer) SetDisplayAttributes(fg, bg Color, attrs ...DisplayAttribu f = foregroundANSIColors[DefaultColor] } w.WriteRaw(f) - w.WriteRaw([]byte{separator}) + w.WriteRaw([]byte{formatSeparator}) b, ok := backgroundANSIColors[bg] if !ok { b = backgroundANSIColors[DefaultColor] From 8ca0a878590537a23e83314182e01f3126da4a11 Mon Sep 17 00:00:00 2001 From: Mateusz Drewniak Date: Sun, 23 Jul 2023 17:31:34 +0200 Subject: [PATCH 13/16] Fix suggestions on multiline text --- renderer.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/renderer.go b/renderer.go index 94cb3e5c..3d440d62 100644 --- a/renderer.go +++ b/renderer.go @@ -127,7 +127,8 @@ func (r *Renderer) renderCompletion(buf *Buffer, completions *CompletionManager) formatted = formatted[completions.verticalScroll : completions.verticalScroll+windowHeight] r.prepareArea(windowHeight) - cursor := positionAtEndOfString(prefix+buf.Document().TextBeforeCursor(), r.col-prefixWidth) + cursor := positionAtEndOfString(buf.Document().TextBeforeCursor(), r.col-prefixWidth) + cursor.X += prefixWidth x := cursor.X if x+width >= r.col { cursor = r.backward(cursor, x+width-r.col) From deb8d2b8c59cf48137a731e5ca535aec513ffbf9 Mon Sep 17 00:00:00 2001 From: Mateusz Drewniak Date: Sun, 23 Jul 2023 18:01:16 +0200 Subject: [PATCH 14/16] Improve suggestion selection --- completion.go | 3 ++- prompt.go | 24 ++++++++++++++++-------- 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/completion.go b/completion.go index 35fea01b..50953e67 100644 --- a/completion.go +++ b/completion.go @@ -99,7 +99,8 @@ func (c *CompletionManager) update() { } if c.selected >= len(c.tmp) { - c.Reset() + c.selected = -1 + c.verticalScroll = 0 } else if c.selected < -1 { c.selected = len(c.tmp) - 1 c.verticalScroll = len(c.tmp) - max diff --git a/prompt.go b/prompt.go index 8de1517c..8a32c723 100644 --- a/prompt.go +++ b/prompt.go @@ -154,7 +154,9 @@ func (p *Prompt) feed(b []byte) (shouldExit bool, rerender bool, userInput *User p.Buffer.lastKeyStroke = key // completion completing := p.completion.Completing() - p.handleCompletionKeyBinding(b, key, completing) + if p.handleCompletionKeyBinding(b, key, completing) { + return false, true, nil + } cols := p.renderer.UserInputColumns() rows := p.renderer.row @@ -238,7 +240,7 @@ func (p *Prompt) feed(b []byte) (shouldExit bool, rerender bool, userInput *User return shouldExit, rerender, userInput } -func (p *Prompt) handleCompletionKeyBinding(b []byte, key Key, completing bool) { +func (p *Prompt) handleCompletionKeyBinding(b []byte, key Key, completing bool) (handled bool) { p.completion.shouldUpdate = true cols := p.renderer.UserInputColumns() rows := p.renderer.row @@ -252,16 +254,19 @@ keySwitch: p.updateSuggestions(func() { p.completion.Next() }) + return true } case ControlI: p.updateSuggestions(func() { p.completion.Next() }) + return true case Up: if completing { p.updateSuggestions(func() { p.completion.Previous() }) + return true } case Tab: if completionLen > 0 { @@ -270,7 +275,7 @@ keySwitch: p.completion.Next() }) - break + return true } // if there are no suggestions insert indentation @@ -286,13 +291,14 @@ keySwitch: } } p.Buffer.InsertTextMoveCursor(string(newBytes), cols, rows, false) + return true case BackTab: if completionLen > 0 { // If there are any suggestions, select the previous one p.updateSuggestions(func() { p.completion.Previous() }) - break + return true } text := p.Buffer.Document().CurrentLineBeforeCursor() @@ -302,6 +308,7 @@ keySwitch: } } p.Buffer.DeleteBeforeCursor(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) @@ -315,6 +322,7 @@ keySwitch: } p.completion.Reset() } + return false } func (p *Prompt) updateSuggestions(fn func()) { @@ -519,7 +527,7 @@ func (p *Prompt) CursorLeft(count istrings.RuneNumber) bool { cols := p.renderer.UserInputColumns() previousCursor := b.DisplayCursorPosition(cols) - rerender := p.Buffer.CursorLeft(count, cols, p.renderer.row) || p.completionReset + rerender := p.Buffer.CursorLeft(count, cols, p.renderer.row) || p.completionReset || len(p.completion.tmp) > 0 if rerender { return true } @@ -538,7 +546,7 @@ func (p *Prompt) CursorRight(count istrings.RuneNumber) bool { cols := p.renderer.UserInputColumns() previousCursor := b.DisplayCursorPosition(cols) - rerender := p.Buffer.CursorRight(count, cols, p.renderer.row) || p.completionReset + rerender := p.Buffer.CursorRight(count, cols, p.renderer.row) || p.completionReset || len(p.completion.tmp) > 0 if rerender { return true } @@ -557,7 +565,7 @@ func (p *Prompt) CursorUp(count int) bool { cols := p.renderer.UserInputColumns() previousCursor := b.DisplayCursorPosition(cols) - rerender := p.Buffer.CursorUp(count, cols, p.renderer.row) || p.completionReset + rerender := p.Buffer.CursorUp(count, cols, p.renderer.row) || p.completionReset || len(p.completion.tmp) > 0 if rerender { return true } @@ -576,7 +584,7 @@ func (p *Prompt) CursorDown(count int) bool { cols := p.renderer.UserInputColumns() previousCursor := b.DisplayCursorPosition(cols) - rerender := p.Buffer.CursorDown(count, cols, p.renderer.row) || p.completionReset + rerender := p.Buffer.CursorDown(count, cols, p.renderer.row) || p.completionReset || len(p.completion.tmp) > 0 if rerender { return true } From c32fbe4c112c2e64dc7f333ae4029841ebf750b2 Mon Sep 17 00:00:00 2001 From: Mateusz Drewniak Date: Sun, 23 Jul 2023 18:30:22 +0200 Subject: [PATCH 15/16] Fix the examples --- CHANGELOG.md | 2 ++ _example/exec-command/main.go | 9 ++------- _example/http-prompt/main.go | 5 ++--- _example/live-prefix/main.go | 9 +++++++-- 4 files changed, 13 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c370e03..8672e82d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -74,6 +74,8 @@ This release aims to make the code a bit cleaner, fix a couple of bugs and provi - Rename `prompt.OptionShowCompletionAtStart` to `prompt.WithShowCompletionAtStart` - Rename `prompt.OptionBreakLineCallback` to `prompt.WithBreakLineCallback` - Rename `prompt.OptionExitChecker` to `prompt.WithExitChecker` +- Change the signature of `Completer` from `func(Document) []Suggest` to `func(Document) (suggestions []Suggest, startChar, endChar istrings.RuneNumber)` +- Change the signature of `KeyBindFunc` from `func(*Buffer)` to `func(p *Prompt) (rerender bool)` ### Fixed diff --git a/_example/exec-command/main.go b/_example/exec-command/main.go index 02766bf7..703fa52e 100644 --- a/_example/exec-command/main.go +++ b/_example/exec-command/main.go @@ -1,6 +1,7 @@ package main import ( + "fmt" "os" "os/exec" @@ -9,6 +10,7 @@ import ( func executor(t string) { if t != "bash" { + fmt.Println("Sorry, I don't understand.") return } @@ -19,16 +21,9 @@ func executor(t string) { cmd.Run() } -func completer(t prompt.Document) []prompt.Suggest { - return []prompt.Suggest{ - {Text: "bash"}, - } -} - func main() { p := prompt.New( executor, - prompt.WithCompleter(completer), ) p.Run() } diff --git a/_example/http-prompt/main.go b/_example/http-prompt/main.go index b77825db..df8e1091 100644 --- a/_example/http-prompt/main.go +++ b/_example/http-prompt/main.go @@ -159,13 +159,12 @@ func executor(in string) { } func completer(in prompt.Document) ([]prompt.Suggest, istrings.RuneNumber, istrings.RuneNumber) { - currentIndex := in.CurrentRuneIndex() + endIndex := in.CurrentRuneIndex() w := in.GetWordBeforeCursor() if w == "" { return []prompt.Suggest{}, 0, 0 } - startIndex := currentIndex - istrings.RuneCount(w) - endIndex := currentIndex + startIndex := endIndex - istrings.RuneCount(w) return prompt.FilterHasPrefix(suggestions, w, true), startIndex, endIndex } diff --git a/_example/live-prefix/main.go b/_example/live-prefix/main.go index 4fe160a5..d4f9cb3c 100644 --- a/_example/live-prefix/main.go +++ b/_example/live-prefix/main.go @@ -4,6 +4,7 @@ import ( "fmt" prompt "github.com/elk-language/go-prompt" + istrings "github.com/elk-language/go-prompt/strings" ) var LivePrefix string = ">>> " @@ -17,14 +18,18 @@ func executor(in string) { LivePrefix = in + "> " } -func completer(in prompt.Document) []prompt.Suggest { +func completer(in prompt.Document) ([]prompt.Suggest, istrings.RuneNumber, istrings.RuneNumber) { + endIndex := in.CurrentRuneIndex() + w := in.GetWordBeforeCursor() + startIndex := endIndex - istrings.RuneCount(w) + 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) + return prompt.FilterHasPrefix(s, w, true), startIndex, endIndex } func changeLivePrefix() string { From 5c12400184198ab2ac0e25864a13caa3a8a4a507 Mon Sep 17 00:00:00 2001 From: Mateusz Drewniak Date: Sun, 23 Jul 2023 18:34:54 +0200 Subject: [PATCH 16/16] Fix linter warnings --- lexer_test.go | 2 +- prompt.go | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/lexer_test.go b/lexer_test.go index bd3fd632..8b14f481 100644 --- a/lexer_test.go +++ b/lexer_test.go @@ -69,7 +69,7 @@ func TestEagerLexerNext(t *testing.T) { func charLex(s string) []Token { var result []Token - for i, _ := range s { + for i := range s { result = append(result, NewSimpleToken(0, istrings.ByteNumber(i))) } diff --git a/prompt.go b/prompt.go index 8a32c723..615ce15a 100644 --- a/prompt.go +++ b/prompt.go @@ -160,7 +160,6 @@ func (p *Prompt) feed(b []byte) (shouldExit bool, rerender bool, userInput *User cols := p.renderer.UserInputColumns() rows := p.renderer.row - rerender = true switch key { case Enter, ControlJ, ControlM: