From bc0ab6e4205b6a85dc815a3958742d7500cf3ce5 Mon Sep 17 00:00:00 2001 From: Mateusz Drewniak Date: Sat, 15 Jul 2023 13:35:51 +0200 Subject: [PATCH 1/4] Rename `strings.StringWidth` to `strings.Width` add `strings.GetWidth` --- CHANGELOG.md | 4 ++-- README.md | 5 ++++- buffer.go | 2 +- completion.go | 26 +++++++++++++------------- completion_test.go | 18 +++++++++--------- document.go | 6 +++--- position.go | 10 +++++----- position_test.go | 2 +- render.go | 19 +++++++++---------- render_test.go | 4 ++-- strings/strings.go | 7 +++++++ strings/units.go | 2 +- 12 files changed, 57 insertions(+), 48 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 816fa3ac..b2bce704 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,7 +42,7 @@ This release aims to make the code a bit cleaner, fix a couple of bugs and provi - Tab will insert a single indentation level when there are no suggestions - Shift + Tab will delete a single indentation level when there are no suggestions and the line before the cursor consists only of indentation (spaces) - Make `Completer` optional when creating a new `prompt.Prompt`. Change the signature of `prompt.New` from `func New(Executor, Completer, ...Option) *Prompt` to `func New(Executor, ...Option) *Prompt` -- Make `prefix` optional in `prompt.Input`. Change the signature of `prompt.Input` from `func Input(string, ...Option) string` to `func Input(...Option) string`. +- Make `prefix` and `completer` optional in `prompt.Input`. Change the signature of `prompt.Input` from `func Input(string, Completer, ...Option) string` to `func Input(...Option) string`. - Rename `prompt.ConsoleParser` to `prompt.Reader` and make it embed `io.ReadCloser` - Rename `prompt.ConsoleWriter` to `prompt.Writer` and make it embed `io.Writer` and `io.StringWriter` - Rename `prompt.OptionTitle` to `prompt.WithTitle` @@ -79,7 +79,7 @@ This release aims to make the code a bit cleaner, fix a couple of bugs and provi - Make pasting multiline text work properly - Make pasting text with tabs work properly (tabs get replaced with indentation -- spaces) -- Introduce `strings.ByteNumber`, `strings.RuneNumber`, `strings.StringWidth` to reduce the ambiguity of when to use which of the three main units used by this library to measure string length and index parts of strings. Several subtle bugs (using the wrong unit) causing panics have been fixed this way. +- Introduce `strings.ByteNumber`, `strings.RuneNumber`, `strings.Width` to reduce the ambiguity of when to use which of the three main units used by this library to measure string length and index parts of strings. Several subtle bugs (using the wrong unit) causing panics have been fixed this way. - Remove a `/dev/tty` leak in `prompt.PosixReader` (old `prompt.PosixParser`) ### Removed diff --git a/README.md b/README.md index 19ea9d0f..5c9a36dc 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,10 @@ func completer(d prompt.Document) []prompt.Suggest { func main() { fmt.Println("Please select table.") - t := prompt.Input("> ", completer) + t := prompt.Input( + prompt.WithPrefix("> "), + prompt.WithCompleter(completer), + ) fmt.Println("You selected " + t) } ``` diff --git a/buffer.go b/buffer.go index a1d44e6e..0336bd45 100644 --- a/buffer.go +++ b/buffer.go @@ -37,7 +37,7 @@ func (b *Buffer) Document() (d *Document) { // DisplayCursorPosition returns the cursor position on rendered text on terminal emulators. // So if Document is "日本(cursor)語", DisplayedCursorPosition returns 4 because '日' and '本' are double width characters. -func (b *Buffer) DisplayCursorPosition(columns istrings.StringWidth) Position { +func (b *Buffer) DisplayCursorPosition(columns istrings.Width) Position { return b.Document().DisplayCursorPosition(columns) } diff --git a/completion.go b/completion.go index 1ee6713c..1eabd56a 100644 --- a/completion.go +++ b/completion.go @@ -17,8 +17,8 @@ const ( ) var ( - leftMargin = runewidth.StringWidth(leftPrefix + leftSuffix) - rightMargin = runewidth.StringWidth(rightPrefix + rightSuffix) + leftMargin = istrings.GetWidth(leftPrefix + leftSuffix) + rightMargin = istrings.GetWidth(rightPrefix + rightSuffix) completionMargin = leftMargin + rightMargin ) @@ -114,18 +114,18 @@ func deleteBreakLineCharacters(s string) string { return s } -func formatTexts(o []string, max int, prefix, suffix string) (new []string, width int) { +func formatTexts(o []string, max istrings.Width, prefix, suffix string) (new []string, width istrings.Width) { l := len(o) n := make([]string, l) - lenPrefix := runewidth.StringWidth(prefix) - lenSuffix := runewidth.StringWidth(suffix) - lenShorten := runewidth.StringWidth(shortenSuffix) + lenPrefix := istrings.GetWidth(prefix) + lenSuffix := istrings.GetWidth(suffix) + lenShorten := istrings.GetWidth(shortenSuffix) min := lenPrefix + lenSuffix + lenShorten for i := 0; i < l; i++ { o[i] = deleteBreakLineCharacters(o[i]) - w := runewidth.StringWidth(o[i]) + w := istrings.GetWidth(o[i]) if width < w { width = w } @@ -142,21 +142,21 @@ func formatTexts(o []string, max int, prefix, suffix string) (new []string, widt } for i := 0; i < l; i++ { - x := runewidth.StringWidth(o[i]) + x := istrings.GetWidth(o[i]) if x <= width { - spaces := strings.Repeat(" ", width-x) + spaces := strings.Repeat(" ", int(width-x)) n[i] = prefix + o[i] + spaces + suffix } else if x > width { - x := runewidth.Truncate(o[i], width, shortenSuffix) + x := runewidth.Truncate(o[i], int(width), shortenSuffix) // When calling runewidth.Truncate("您好xxx您好xxx", 11, "...") returns "您好xxx..." // But the length of this result is 10. So we need fill right using runewidth.FillRight. - n[i] = prefix + runewidth.FillRight(x, width) + suffix + n[i] = prefix + runewidth.FillRight(x, int(width)) + suffix } } return n, lenPrefix + width + lenSuffix } -func formatSuggestions(suggests []Suggest, max int) (new []Suggest, width istrings.StringWidth) { +func formatSuggestions(suggests []Suggest, max istrings.Width) (new []Suggest, width istrings.Width) { num := len(suggests) new = make([]Suggest, num) @@ -178,7 +178,7 @@ func formatSuggestions(suggests []Suggest, max int) (new []Suggest, width istrin for i := 0; i < num; i++ { new[i] = Suggest{Text: left[i], Description: right[i]} } - return new, istrings.StringWidth(leftWidth + rightWidth) + return new, istrings.Width(leftWidth + rightWidth) } // Constructor option for CompletionManager. diff --git a/completion_test.go b/completion_test.go index c2d13446..959d86f1 100644 --- a/completion_test.go +++ b/completion_test.go @@ -11,8 +11,8 @@ func TestFormatShortSuggestion(t *testing.T) { var scenarioTable = []struct { in []Suggest expected []Suggest - max int - exWidth istrings.StringWidth + max istrings.Width + exWidth istrings.Width }{ { in: []Suggest{ @@ -40,7 +40,7 @@ func TestFormatShortSuggestion(t *testing.T) { {Text: " coconut ", Description: " This is coconut. "}, }, max: 100, - exWidth: istrings.StringWidth(len(" apple " + " This is apple. ")), + exWidth: istrings.Width(len(" apple " + " This is apple. ")), }, { in: []Suggest{ @@ -84,7 +84,7 @@ func TestFormatShortSuggestion(t *testing.T) { {Text: " --include-extended-apis ", Description: " --------------... "}, }, max: 50, - exWidth: istrings.StringWidth(len(" --include-extended-apis " + " ---------------...")), + exWidth: istrings.Width(len(" --include-extended-apis " + " ---------------...")), }, { in: []Suggest{ @@ -104,7 +104,7 @@ func TestFormatShortSuggestion(t *testing.T) { {Text: " --include-extended-apis ", Description: " If true, include definitions of new APIs via calls to the API server. [default true] "}, }, max: 500, - exWidth: istrings.StringWidth(len(" --include-extended-apis " + " If true, include definitions of new APIs via calls to the API server. [default true] ")), + exWidth: istrings.Width(len(" --include-extended-apis " + " If true, include definitions of new APIs via calls to the API server. [default true] ")), }, } @@ -123,8 +123,8 @@ func TestFormatText(t *testing.T) { var scenarioTable = []struct { in []string expected []string - max int - exWidth int + max istrings.Width + exWidth istrings.Width }{ { in: []string{ @@ -163,7 +163,7 @@ func TestFormatText(t *testing.T) { "", "", }, - max: len(" " + " " + shortenSuffix), + max: istrings.GetWidth(" " + " " + shortenSuffix), exWidth: 0, }, { @@ -178,7 +178,7 @@ func TestFormatText(t *testing.T) { " coconut ", }, max: 100, - exWidth: len(" coconut "), + exWidth: istrings.GetWidth(" coconut "), }, { in: []string{ diff --git a/document.go b/document.go index 642c098c..94eddc02 100644 --- a/document.go +++ b/document.go @@ -35,7 +35,7 @@ func (d *Document) LastKeyStroke() Key { // DisplayCursorPosition returns the cursor position on rendered text on terminal emulators. // So if Document is "日本(cursor)語", DisplayedCursorPosition returns 4 because '日' and '本' are double width characters. -func (d *Document) DisplayCursorPosition(columns istrings.StringWidth) Position { +func (d *Document) DisplayCursorPosition(columns istrings.Width) Position { str := utf8string.NewString(d.Text).Slice(0, int(d.cursorPosition)) return positionAtEndOfString(str, columns) } @@ -381,12 +381,12 @@ func (d *Document) GetCursorRightPosition(count istrings.RuneNumber) istrings.Ru } // Get the current cursor position. -func (d *Document) GetCursorPosition(columns istrings.StringWidth) Position { +func (d *Document) GetCursorPosition(columns istrings.Width) Position { return positionAtEndOfString(d.TextBeforeCursor(), columns) } // Get the position of the end of the current text. -func (d *Document) GetEndOfTextPosition(columns istrings.StringWidth) Position { +func (d *Document) GetEndOfTextPosition(columns istrings.Width) Position { return positionAtEndOfString(d.Text, columns) } diff --git a/position.go b/position.go index decccd40..14ef66f2 100644 --- a/position.go +++ b/position.go @@ -14,7 +14,7 @@ import ( // (0, 0) represents the top-left corner of the prompt, // while (n, n) the bottom-right corner. type Position struct { - X istrings.StringWidth + X istrings.Width Y int } @@ -47,16 +47,16 @@ func (p Position) Subtract(other Position) Position { // positionAtEndOfString calculates the position of the // p at the end of the given string. -func positionAtEndOfString(str string, columns istrings.StringWidth) Position { +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. -func positionAtEndOfReader(reader io.RuneReader, columns istrings.StringWidth) Position { +func positionAtEndOfReader(reader io.RuneReader, columns istrings.Width) Position { var down int - var right istrings.StringWidth + var right istrings.Width charLoop: for { @@ -80,7 +80,7 @@ charLoop: down++ right = 0 default: - right += istrings.StringWidth(runewidth.RuneWidth(char)) + right += istrings.Width(runewidth.RuneWidth(char)) if right == columns { right = 0 down++ diff --git a/position_test.go b/position_test.go index 49984d0b..cc9864ce 100644 --- a/position_test.go +++ b/position_test.go @@ -13,7 +13,7 @@ import ( func TestPositionAtEndOfString(t *testing.T) { tests := map[string]struct { input string - columns istrings.StringWidth + columns istrings.Width want Position }{ "empty": { diff --git a/render.go b/render.go index 7cc69e05..86c0a137 100644 --- a/render.go +++ b/render.go @@ -6,7 +6,6 @@ import ( "github.com/elk-language/go-prompt/debug" istrings "github.com/elk-language/go-prompt/strings" - runewidth "github.com/mattn/go-runewidth" ) // Render to render prompt information from state of Buffer. @@ -16,7 +15,7 @@ type Render struct { breakLineCallback func(*Document) title string row uint16 - col istrings.StringWidth + col istrings.Width previousCursor Position @@ -83,7 +82,7 @@ func (r *Render) prepareArea(lines int) { // UpdateWinSize called when window size is changed. func (r *Render) UpdateWinSize(ws *WinSize) { r.row = ws.Row - r.col = istrings.StringWidth(ws.Col) + r.col = istrings.Width(ws.Col) } func (r *Render) renderWindowTooSmall() { @@ -103,7 +102,7 @@ func (r *Render) renderCompletion(buf *Buffer, completions *CompletionManager) { prefix := r.getCurrentPrefix() formatted, width := formatSuggestions( suggestions, - int(r.col)-runewidth.StringWidth(prefix)-1, // -1 means a width of scrollbar + r.col-istrings.GetWidth(prefix)-1, // -1 means a width of scrollbar ) // +1 means a width of scrollbar. width++ @@ -193,14 +192,14 @@ func (r *Render) Render(buffer *Buffer, completion *CompletionManager, lexer Lex line := buffer.Text() prefix := r.getCurrentPrefix() - prefixWidth := istrings.StringWidth(runewidth.StringWidth(prefix)) + prefixWidth := istrings.GetWidth(prefix) cursor := positionAtEndOfString(prefix+line, r.col) // prepare area y := cursor.Y h := y + 1 + int(completion.max) - if h > int(r.row) || completionMargin > int(r.col) { + if h > int(r.row) || completionMargin > r.col { r.renderWindowTooSmall() return } @@ -232,14 +231,14 @@ func (r *Render) Render(buffer *Buffer, completion *CompletionManager, lexer Lex r.renderCompletion(buffer, completion) if suggest, ok := completion.GetSelectedSuggestion(); ok { - cursor = r.backward(cursor, istrings.StringWidth(runewidth.StringWidth(buffer.Document().GetWordBeforeCursorUntilSeparator(completion.wordSeparator)))) + 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.StringWidth(runewidth.StringWidth(suggest.Text)) + cursor.X += istrings.GetWidth(suggest.Text) endOfSuggestionPos := cursor rest := buffer.Document().TextAfterCursor() @@ -321,7 +320,7 @@ func (r *Render) clear(cursor Position) { // backward moves cursor to backward from a current cursor position // regardless there is a line break. -func (r *Render) backward(from Position, n istrings.StringWidth) Position { +func (r *Render) backward(from Position, n istrings.Width) Position { return r.move(from, Position{X: from.X - n, Y: from.Y}) } @@ -353,7 +352,7 @@ func clamp(high, low, x float64) float64 { } } -func alignNextLine(r *Render, col istrings.StringWidth) { +func alignNextLine(r *Render, col istrings.Width) { r.out.CursorDown(1) if _, err := r.out.WriteString("\r"); err != nil { panic(err) diff --git a/render_test.go b/render_test.go index d67a7ef2..e8a64ce0 100644 --- a/render_test.go +++ b/render_test.go @@ -18,8 +18,8 @@ func TestFormatCompletion(t *testing.T) { prefix string suffix string expected []Suggest - maxWidth int - expectedWidth istrings.StringWidth + maxWidth istrings.Width + expectedWidth istrings.Width }{ { scenario: "", diff --git a/strings/strings.go b/strings/strings.go index d89ec60f..3406ca82 100644 --- a/strings/strings.go +++ b/strings/strings.go @@ -2,6 +2,8 @@ package strings import ( "unicode/utf8" + + "github.com/mattn/go-runewidth" ) // Get the length of the string in bytes. @@ -14,6 +16,11 @@ func RuneCount(s string) RuneNumber { return RuneNumber(utf8.RuneCountInString(s)) } +// Get the width of the string (how many columns it takes upt in the terminal). +func GetWidth(s string) Width { + return Width(runewidth.StringWidth(s)) +} + // IndexNotByte is similar with strings.IndexByte but showing the opposite behavior. func IndexNotByte(s string, c byte) ByteNumber { n := len(s) diff --git a/strings/units.go b/strings/units.go index 106b3432..2f74a0e9 100644 --- a/strings/units.go +++ b/strings/units.go @@ -10,4 +10,4 @@ type RuneNumber int // Numeric type that represents the visible // width of characters in a string as seen in a terminal emulator. -type StringWidth int +type Width int From 2cde6ad172c82d77c295b6f589fbfdb0c6b07ed2 Mon Sep 17 00:00:00 2001 From: Mateusz Drewniak Date: Sat, 15 Jul 2023 13:37:50 +0200 Subject: [PATCH 2/4] Get rid of an unused function --- render.go | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/render.go b/render.go index 86c0a137..b79cccf5 100644 --- a/render.go +++ b/render.go @@ -46,18 +46,12 @@ func (r *Render) Setup() { } } -// getCurrentPrefix to get current prefix. -// If live-prefix is enabled, return live-prefix. -func (r *Render) getCurrentPrefix() string { - return r.prefixCallback() -} - func (r *Render) renderPrefix() { r.out.SetColor(r.prefixTextColor, r.prefixBGColor, false) if _, err := r.out.WriteString("\r"); err != nil { panic(err) } - if _, err := r.out.WriteString(r.getCurrentPrefix()); err != nil { + if _, err := r.out.WriteString(r.prefixCallback()); err != nil { panic(err) } r.out.SetColor(DefaultColor, DefaultColor, false) @@ -99,7 +93,7 @@ func (r *Render) renderCompletion(buf *Buffer, completions *CompletionManager) { if len(suggestions) == 0 { return } - prefix := r.getCurrentPrefix() + prefix := r.prefixCallback() formatted, width := formatSuggestions( suggestions, r.col-istrings.GetWidth(prefix)-1, // -1 means a width of scrollbar @@ -191,7 +185,7 @@ func (r *Render) Render(buffer *Buffer, completion *CompletionManager, lexer Lex r.clear(r.previousCursor) line := buffer.Text() - prefix := r.getCurrentPrefix() + prefix := r.prefixCallback() prefixWidth := istrings.GetWidth(prefix) cursor := positionAtEndOfString(prefix+line, r.col) @@ -287,7 +281,7 @@ func (r *Render) lex(lexer Lexer, input string) { // BreakLine to break line. func (r *Render) BreakLine(buffer *Buffer, lexer Lexer) { // Erasing and Render - cursor := positionAtEndOfString(buffer.Document().TextBeforeCursor()+r.getCurrentPrefix(), r.col) + cursor := positionAtEndOfString(buffer.Document().TextBeforeCursor()+r.prefixCallback(), r.col) r.clear(cursor) r.renderPrefix() From ccb34ce76d2e9513c84af82da46e1848d81f2a46 Mon Sep 17 00:00:00 2001 From: Mateusz Drewniak Date: Sun, 16 Jul 2023 10:26:13 +0200 Subject: [PATCH 3/4] Add multiline prefixes --- _example/bang-executor/main.go | 3 +- constructor.go | 4 +- prompt.go | 29 +++---- render.go | 142 ++++++++++++++++++++++++--------- 4 files changed, 122 insertions(+), 56 deletions(-) diff --git a/_example/bang-executor/main.go b/_example/bang-executor/main.go index 786ef551..b6bbb3c0 100644 --- a/_example/bang-executor/main.go +++ b/_example/bang-executor/main.go @@ -10,6 +10,7 @@ import ( func main() { p := prompt.New( executor, + prompt.WithPrefix(">>> "), prompt.WithExecuteOnEnterCallback(ExecuteOnEnter), ) @@ -18,7 +19,7 @@ func main() { func ExecuteOnEnter(input string, indentSize int) (int, bool) { char, _ := utf8.DecodeLastRuneInString(input) - return 1, char == '!' + return 0, char == '!' } func executor(s string) { diff --git a/constructor.go b/constructor.go index ecb18a43..e9fec4ee 100644 --- a/constructor.go +++ b/constructor.go @@ -13,7 +13,7 @@ const DefaultIndentSize = 2 // that constitute a single indentation level. func WithIndentSize(i int) Option { return func(p *Prompt) error { - p.indentSize = i + p.renderer.indentSize = i return nil } } @@ -319,6 +319,7 @@ func New(executor Executor, opts ...Option) *Prompt { reader: NewStdinReader(), renderer: &Render{ out: defaultWriter, + indentSize: DefaultIndentSize, prefixCallback: DefaultPrefixCallback, prefixTextColor: Blue, prefixBGColor: DefaultColor, @@ -342,7 +343,6 @@ func New(executor Executor, opts ...Option) *Prompt { history: NewHistory(), completion: NewCompletionManager(6), executeOnEnterCallback: DefaultExecuteOnEnterCallback, - indentSize: DefaultIndentSize, keyBindMode: EmacsKeyBind, // All the above assume that bash is running in the default Emacs setting } diff --git a/prompt.go b/prompt.go index 217b8fb4..2891e302 100644 --- a/prompt.go +++ b/prompt.go @@ -2,6 +2,8 @@ package prompt import ( "bytes" + "fmt" + "log" "os" "strings" "time" @@ -50,7 +52,6 @@ type Prompt struct { ASCIICodeBindings []ASCIICodeBind keyBindMode KeyBindMode completionOnDown bool - indentSize int // How many spaces constitute a single indentation level exitChecker ExitChecker executeOnEnterCallback ExecuteOnEnterCallback skipClose bool @@ -131,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) @@ -150,11 +151,11 @@ func (p *Prompt) feed(b []byte) (shouldExit bool, userInput *UserInput) { keySwitch: switch key { case Enter, ControlJ, ControlM: - indent, execute := p.executeOnEnterCallback(p.buf.Text(), p.indentSize) + indent, execute := p.executeOnEnterCallback(p.buf.Text(), p.renderer.indentSize) if !execute { p.buf.NewLine(false) var indentStrBuilder strings.Builder - indentUnitCount := indent * p.indentSize + indentUnitCount := indent * p.renderer.indentSize for i := 0; i < indentUnitCount; i++ { indentStrBuilder.WriteRune(IndentUnit) } @@ -180,7 +181,7 @@ keySwitch: for _, byt := range b { switch byt { case '\t': - for i := 0; i < p.indentSize; i++ { + for i := 0; i < p.renderer.indentSize; i++ { newBytes = append(newBytes, IndentUnit) } default: @@ -201,7 +202,7 @@ keySwitch: break keySwitch } } - p.buf.DeleteBeforeCursor(istrings.RuneNumber(p.indentSize)) + p.buf.DeleteBeforeCursor(istrings.RuneNumber(p.renderer.indentSize)) case ControlC: p.renderer.BreakLine(p.buf, p.lexer) p.buf = NewBuffer() @@ -395,7 +396,7 @@ func (p *Prompt) readBuffer(bufCh chan []byte, stopCh chan struct{}) { // translate \t into two spaces // to avoid problems with cursor positions case '\t': - for i := 0; i < p.indentSize; i++ { + for i := 0; i < p.renderer.indentSize; i++ { newBytes = append(newBytes, IndentUnit) } default: diff --git a/render.go b/render.go index b79cccf5..599579ad 100644 --- a/render.go +++ b/render.go @@ -3,11 +3,14 @@ package prompt import ( "runtime" "strings" + "unicode/utf8" "github.com/elk-language/go-prompt/debug" istrings "github.com/elk-language/go-prompt/strings" ) +const multilinePrefixCharacter = '.' + // Render to render prompt information from state of Buffer. type Render struct { out Writer @@ -16,6 +19,7 @@ type Render struct { title string row uint16 col istrings.Width + indentSize int // How many spaces constitute a single indentation level previousCursor Position @@ -46,12 +50,12 @@ func (r *Render) Setup() { } } -func (r *Render) renderPrefix() { +func (r *Render) renderPrefix(prefix string) { r.out.SetColor(r.prefixTextColor, r.prefixBGColor, false) if _, err := r.out.WriteString("\r"); err != nil { panic(err) } - if _, err := r.out.WriteString(r.prefixCallback()); err != nil { + if _, err := r.out.WriteString(prefix); err != nil { panic(err) } r.out.SetColor(DefaultColor, DefaultColor, false) @@ -184,10 +188,11 @@ func (r *Render) Render(buffer *Buffer, completion *CompletionManager, lexer Lex defer func() { debug.AssertNoError(r.out.Flush()) }() r.clear(r.previousCursor) - line := buffer.Text() + text := buffer.Text() prefix := r.prefixCallback() prefixWidth := istrings.GetWidth(prefix) - cursor := positionAtEndOfString(prefix+line, r.col) + cursor := positionAtEndOfString(text, r.col) + cursor.X += prefixWidth // prepare area y := cursor.Y @@ -202,25 +207,14 @@ func (r *Render) Render(buffer *Buffer, completion *CompletionManager, lexer Lex r.out.HideCursor() defer r.out.ShowCursor() - r.renderPrefix() - - if lexer != nil { - r.lex(lexer, line) - } else { - r.out.SetColor(r.inputTextColor, r.inputBGColor, false) - if _, err := r.out.WriteString(line); err != nil { - panic(err) - } - } + r.renderText(lexer, text) r.out.SetColor(DefaultColor, DefaultColor, false) r.lineWrap(&cursor) targetCursor := buffer.DisplayCursorPosition(r.col) - if targetCursor.Y == 0 { - targetCursor.X += prefixWidth - } + targetCursor.X += prefixWidth cursor = r.move(cursor, targetCursor) r.renderCompletion(buffer, completion) @@ -237,13 +231,7 @@ func (r *Render) Render(buffer *Buffer, completion *CompletionManager, lexer Lex rest := buffer.Document().TextAfterCursor() - if lexer != nil { - r.lex(lexer, rest) - } else { - if _, err := r.out.WriteString(rest); err != nil { - panic(err) - } - } + r.renderText(lexer, text) r.out.SetColor(DefaultColor, DefaultColor, false) @@ -256,25 +244,109 @@ func (r *Render) Render(buffer *Buffer, completion *CompletionManager, lexer Lex r.previousCursor = cursor } +func (r *Render) renderText(lexer Lexer, text string) { + if lexer != nil { + r.lex(lexer, text) + return + } + + prefix := r.prefixCallback() + multilinePrefix := r.getMultilinePrefix(prefix) + firstIteration := true + var lineBuffer strings.Builder + for _, char := range text { + lineBuffer.WriteRune(char) + if char != '\n' { + continue + } + + r.renderLine(prefix, lineBuffer.String(), r.inputTextColor) + lineBuffer.Reset() + if firstIteration { + prefix = multilinePrefix + firstIteration = false + } + } + + r.renderLine(prefix, lineBuffer.String(), r.inputTextColor) +} + +func (r *Render) renderLine(prefix, line string, color Color) { + r.renderPrefix(prefix) + r.writeString(line, color) +} + +func (r *Render) writeString(text string, color Color) { + r.out.SetColor(color, r.inputBGColor, false) + if _, err := r.out.WriteString(text); err != nil { + panic(err) + } +} + +func (r *Render) getMultilinePrefix(prefix string) string { + var spaceCount int + var dotCount int + var nonSpaceCharSeen bool + for { + if len(prefix) == 0 { + break + } + char, size := utf8.DecodeLastRuneInString(prefix) + prefix = prefix[:len(prefix)-size] + if nonSpaceCharSeen { + dotCount++ + continue + } + if char != ' ' { + nonSpaceCharSeen = true + dotCount++ + continue + } + spaceCount++ + } + + var multilinePrefixBuilder strings.Builder + + for i := 0; i < dotCount; i++ { + multilinePrefixBuilder.WriteByte(multilinePrefixCharacter) + } + for i := 0; i < spaceCount; i++ { + multilinePrefixBuilder.WriteByte(IndentUnit) + } + + return multilinePrefixBuilder.String() +} + // lex processes the given input with the given lexer // and writes the result func (r *Render) lex(lexer Lexer, input string) { lexer.Init(input) s := input + prefix := r.prefixCallback() + r.renderPrefix(prefix) + multilinePrefix := r.getMultilinePrefix(prefix) for { token, ok := lexer.Next() if !ok { break } - a := strings.SplitAfter(s, token.Lexeme()) - s = strings.TrimPrefix(s, a[0]) + text := strings.SplitAfter(s, token.Lexeme())[0] + s = strings.TrimPrefix(s, text) - r.out.SetColor(token.Color(), r.inputBGColor, false) - if _, err := r.out.WriteString(a[0]); err != nil { - panic(err) + var lineBuffer strings.Builder + for _, char := range text { + lineBuffer.WriteRune(char) + if char != '\n' { + continue + } + + r.writeString(lineBuffer.String(), token.Color()) + r.renderPrefix(multilinePrefix) + lineBuffer.Reset() } + r.writeString(lineBuffer.String(), token.Color()) } } @@ -284,16 +356,8 @@ func (r *Render) BreakLine(buffer *Buffer, lexer Lexer) { cursor := positionAtEndOfString(buffer.Document().TextBeforeCursor()+r.prefixCallback(), r.col) r.clear(cursor) - r.renderPrefix() - - if lexer != nil { - r.lex(lexer, buffer.Document().Text+"\n") - } else { - r.out.SetColor(r.inputTextColor, r.inputBGColor, false) - if _, err := r.out.WriteString(buffer.Document().Text + "\n"); err != nil { - panic(err) - } - } + text := buffer.Document().Text + "\n" + r.renderText(lexer, text) r.out.SetColor(DefaultColor, DefaultColor, false) From e74a7285c03d7653eee47e661fbd7b2587bd9101 Mon Sep 17 00:00:00 2001 From: Mateusz Drewniak Date: Sun, 16 Jul 2023 12:14:08 +0200 Subject: [PATCH 4/4] Fix multiline prefixes for double width chars --- CHANGELOG.md | 1 + constructor.go | 27 ++-------------- prompt.go | 14 ++++---- render.go | 81 +++++++++++++++++++++++++++++++--------------- render_test.go | 68 +++++++++++++++++++++++++------------- strings/strings.go | 5 +++ 6 files changed, 116 insertions(+), 80 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b2bce704..1e35e0c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,6 +45,7 @@ This release aims to make the code a bit cleaner, fix a couple of bugs and provi - Make `prefix` and `completer` optional in `prompt.Input`. Change the signature of `prompt.Input` from `func Input(string, Completer, ...Option) string` to `func Input(...Option) string`. - Rename `prompt.ConsoleParser` to `prompt.Reader` and make it embed `io.ReadCloser` - Rename `prompt.ConsoleWriter` to `prompt.Writer` and make it embed `io.Writer` and `io.StringWriter` +- Rename `prompt.Render` to `prompt.Renderer` - Rename `prompt.OptionTitle` to `prompt.WithTitle` - Rename `prompt.OptionPrefix` to `prompt.WithPrefix` - Rename `prompt.OptionInitialBufferText` to `prompt.WithInitialText` diff --git a/constructor.go b/constructor.go index e9fec4ee..843f524c 100644 --- a/constructor.go +++ b/constructor.go @@ -312,32 +312,9 @@ func DefaultPrefixCallback() string { // New returns a Prompt with powerful auto-completion. func New(executor Executor, opts ...Option) *Prompt { - defaultWriter := NewStdoutWriter() - registerWriter(defaultWriter) - pt := &Prompt{ - reader: NewStdinReader(), - renderer: &Render{ - out: defaultWriter, - indentSize: DefaultIndentSize, - prefixCallback: DefaultPrefixCallback, - prefixTextColor: Blue, - prefixBGColor: DefaultColor, - inputTextColor: DefaultColor, - inputBGColor: DefaultColor, - previewSuggestionTextColor: Green, - previewSuggestionBGColor: DefaultColor, - suggestionTextColor: White, - suggestionBGColor: Cyan, - selectedSuggestionTextColor: Black, - selectedSuggestionBGColor: Turquoise, - descriptionTextColor: Black, - descriptionBGColor: Turquoise, - selectedDescriptionTextColor: White, - selectedDescriptionBGColor: Cyan, - scrollbarThumbColor: DarkGray, - scrollbarBGColor: Cyan, - }, + reader: NewStdinReader(), + renderer: NewRenderer(), buf: NewBuffer(), executor: executor, history: NewHistory(), diff --git a/prompt.go b/prompt.go index 2891e302..e6ba2961 100644 --- a/prompt.go +++ b/prompt.go @@ -43,7 +43,7 @@ type Completer func(Document) []Suggest type Prompt struct { reader Reader buf *Buffer - renderer *Render + renderer *Renderer executor Executor history *History lexer Lexer @@ -74,7 +74,7 @@ func (p *Prompt) Run() { p.completion.Update(*p.buf.Document()) } - p.renderer.Render(p.buf, p.completion, p.lexer) + p.renderer.Renderer(p.buf, p.completion, p.lexer) bufCh := make(chan []byte, 128) stopReadBufCh := make(chan struct{}) @@ -105,7 +105,7 @@ func (p *Prompt) Run() { p.completion.Update(*p.buf.Document()) - p.renderer.Render(p.buf, p.completion, p.lexer) + p.renderer.Renderer(p.buf, p.completion, p.lexer) if p.exitChecker != nil && p.exitChecker(e.input, true) { p.skipClose = true @@ -117,11 +117,11 @@ func (p *Prompt) Run() { go p.handleSignals(exitCh, winSizeCh, stopHandleSignalCh) } else { p.completion.Update(*p.buf.Document()) - p.renderer.Render(p.buf, p.completion, p.lexer) + p.renderer.Renderer(p.buf, p.completion, p.lexer) } case w := <-winSizeCh: p.renderer.UpdateWinSize(w) - p.renderer.Render(p.buf, p.completion, p.lexer) + p.renderer.Renderer(p.buf, p.completion, p.lexer) case code := <-exitCh: p.renderer.BreakLine(p.buf, p.lexer) p.Close() @@ -337,7 +337,7 @@ func (p *Prompt) Input() string { p.completion.Update(*p.buf.Document()) } - p.renderer.Render(p.buf, p.completion, p.lexer) + p.renderer.Renderer(p.buf, p.completion, p.lexer) bufCh := make(chan []byte, 128) stopReadBufCh := make(chan struct{}) go p.readBuffer(bufCh, stopReadBufCh) @@ -355,7 +355,7 @@ func (p *Prompt) Input() string { return e.input } else { p.completion.Update(*p.buf.Document()) - p.renderer.Render(p.buf, p.completion, p.lexer) + p.renderer.Renderer(p.buf, p.completion, p.lexer) } default: time.Sleep(10 * time.Millisecond) diff --git a/render.go b/render.go index 599579ad..14905c05 100644 --- a/render.go +++ b/render.go @@ -11,8 +11,8 @@ import ( const multilinePrefixCharacter = '.' -// Render to render prompt information from state of Buffer. -type Render struct { +// Renderer to render prompt information from state of Buffer. +type Renderer struct { out Writer prefixCallback PrefixCallback breakLineCallback func(*Document) @@ -42,15 +42,43 @@ type Render struct { scrollbarBGColor Color } +// Build a new Renderer. +func NewRenderer() *Renderer { + defaultWriter := NewStdoutWriter() + registerWriter(defaultWriter) + + return &Renderer{ + out: defaultWriter, + indentSize: DefaultIndentSize, + prefixCallback: DefaultPrefixCallback, + prefixTextColor: Blue, + prefixBGColor: DefaultColor, + inputTextColor: DefaultColor, + inputBGColor: DefaultColor, + previewSuggestionTextColor: Green, + previewSuggestionBGColor: DefaultColor, + suggestionTextColor: White, + suggestionBGColor: Cyan, + selectedSuggestionTextColor: Black, + selectedSuggestionBGColor: Turquoise, + descriptionTextColor: Black, + descriptionBGColor: Turquoise, + selectedDescriptionTextColor: White, + selectedDescriptionBGColor: Cyan, + scrollbarThumbColor: DarkGray, + scrollbarBGColor: Cyan, + } +} + // Setup to initialize console output. -func (r *Render) Setup() { +func (r *Renderer) Setup() { if r.title != "" { r.out.SetTitle(r.title) debug.AssertNoError(r.out.Flush()) } } -func (r *Render) renderPrefix(prefix string) { +func (r *Renderer) renderPrefix(prefix string) { r.out.SetColor(r.prefixTextColor, r.prefixBGColor, false) if _, err := r.out.WriteString("\r"); err != nil { panic(err) @@ -62,13 +90,13 @@ func (r *Render) renderPrefix(prefix string) { } // Close to clear title and erasing. -func (r *Render) Close() { +func (r *Renderer) Close() { r.out.ClearTitle() r.out.EraseDown() debug.AssertNoError(r.out.Flush()) } -func (r *Render) prepareArea(lines int) { +func (r *Renderer) prepareArea(lines int) { for i := 0; i < lines; i++ { r.out.ScrollDown() } @@ -78,12 +106,12 @@ func (r *Render) prepareArea(lines int) { } // UpdateWinSize called when window size is changed. -func (r *Render) UpdateWinSize(ws *WinSize) { +func (r *Renderer) UpdateWinSize(ws *WinSize) { r.row = ws.Row r.col = istrings.Width(ws.Col) } -func (r *Render) renderWindowTooSmall() { +func (r *Renderer) renderWindowTooSmall() { r.out.CursorGoTo(0, 0) r.out.EraseScreen() r.out.SetColor(DarkRed, White, false) @@ -92,7 +120,7 @@ func (r *Render) renderWindowTooSmall() { } } -func (r *Render) renderCompletion(buf *Buffer, completions *CompletionManager) { +func (r *Renderer) renderCompletion(buf *Buffer, completions *CompletionManager) { suggestions := completions.GetSuggestions() if len(suggestions) == 0 { return @@ -178,8 +206,8 @@ func (r *Render) renderCompletion(buf *Buffer, completions *CompletionManager) { r.out.SetColor(DefaultColor, DefaultColor, false) } -// Render renders to the console. -func (r *Render) Render(buffer *Buffer, completion *CompletionManager, lexer Lexer) { +// Renderer renders to the console. +func (r *Renderer) Renderer(buffer *Buffer, completion *CompletionManager, lexer Lexer) { // In situations where a pseudo tty is allocated (e.g. within a docker container), // window size via TIOCGWINSZ is not immediately available and will result in 0,0 dimensions. if r.col == 0 { @@ -244,7 +272,7 @@ func (r *Render) Render(buffer *Buffer, completion *CompletionManager, lexer Lex r.previousCursor = cursor } -func (r *Render) renderText(lexer Lexer, text string) { +func (r *Renderer) renderText(lexer Lexer, text string) { if lexer != nil { r.lex(lexer, text) return @@ -271,19 +299,19 @@ func (r *Render) renderText(lexer Lexer, text string) { r.renderLine(prefix, lineBuffer.String(), r.inputTextColor) } -func (r *Render) renderLine(prefix, line string, color Color) { +func (r *Renderer) renderLine(prefix, line string, color Color) { r.renderPrefix(prefix) r.writeString(line, color) } -func (r *Render) writeString(text string, color Color) { +func (r *Renderer) writeString(text string, color Color) { r.out.SetColor(color, r.inputBGColor, false) if _, err := r.out.WriteString(text); err != nil { panic(err) } } -func (r *Render) getMultilinePrefix(prefix string) string { +func (r *Renderer) getMultilinePrefix(prefix string) string { var spaceCount int var dotCount int var nonSpaceCharSeen bool @@ -293,16 +321,17 @@ func (r *Render) getMultilinePrefix(prefix string) string { } char, size := utf8.DecodeLastRuneInString(prefix) prefix = prefix[:len(prefix)-size] + charWidth := istrings.GetRuneWidth(char) if nonSpaceCharSeen { - dotCount++ + dotCount += int(charWidth) continue } if char != ' ' { nonSpaceCharSeen = true - dotCount++ + dotCount += int(charWidth) continue } - spaceCount++ + spaceCount += int(charWidth) } var multilinePrefixBuilder strings.Builder @@ -319,7 +348,7 @@ func (r *Render) getMultilinePrefix(prefix string) string { // lex processes the given input with the given lexer // and writes the result -func (r *Render) lex(lexer Lexer, input string) { +func (r *Renderer) lex(lexer Lexer, input string) { lexer.Init(input) s := input @@ -351,8 +380,8 @@ func (r *Render) lex(lexer Lexer, input string) { } // BreakLine to break line. -func (r *Render) BreakLine(buffer *Buffer, lexer Lexer) { - // Erasing and Render +func (r *Renderer) BreakLine(buffer *Buffer, lexer Lexer) { + // Erasing and Renderer cursor := positionAtEndOfString(buffer.Document().TextBeforeCursor()+r.prefixCallback(), r.col) r.clear(cursor) @@ -371,27 +400,27 @@ func (r *Render) BreakLine(buffer *Buffer, lexer Lexer) { // 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 *Render) clear(cursor Position) { +func (r *Renderer) clear(cursor Position) { r.move(cursor, Position{}) r.out.EraseDown() } // backward moves cursor to backward from a current cursor position // regardless there is a line break. -func (r *Render) backward(from Position, n istrings.Width) Position { +func (r *Renderer) backward(from Position, n istrings.Width) Position { return r.move(from, Position{X: from.X - n, Y: from.Y}) } // move moves cursor to specified position from the beginning of input // even if there is a line break. -func (r *Render) move(from, to Position) Position { +func (r *Renderer) move(from, to Position) Position { newPosition := from.Subtract(to) r.out.CursorUp(newPosition.Y) r.out.CursorBackward(int(newPosition.X)) return to } -func (r *Render) lineWrap(cursor *Position) { +func (r *Renderer) lineWrap(cursor *Position) { if runtime.GOOS != "windows" && cursor.X > 0 && cursor.X%r.col == 0 { cursor.X = 0 cursor.Y += 1 @@ -410,7 +439,7 @@ func clamp(high, low, x float64) float64 { } } -func alignNextLine(r *Render, col istrings.Width) { +func alignNextLine(r *Renderer, col istrings.Width) { r.out.CursorDown(1) if _, err := r.out.WriteString("\r"); err != nil { panic(err) diff --git a/render_test.go b/render_test.go index e8a64ce0..7f35feea 100644 --- a/render_test.go +++ b/render_test.go @@ -74,29 +74,11 @@ func TestFormatCompletion(t *testing.T) { func TestBreakLineCallback(t *testing.T) { var i int - r := &Render{ - out: &PosixWriter{ - fd: syscall.Stdin, // "write" to stdin just so we don't mess with the output of the tests - }, - prefixCallback: DefaultPrefixCallback, - prefixTextColor: Blue, - prefixBGColor: DefaultColor, - inputTextColor: DefaultColor, - inputBGColor: DefaultColor, - previewSuggestionTextColor: Green, - previewSuggestionBGColor: DefaultColor, - suggestionTextColor: White, - suggestionBGColor: Cyan, - selectedSuggestionTextColor: Black, - selectedSuggestionBGColor: Turquoise, - descriptionTextColor: Black, - descriptionBGColor: Turquoise, - selectedDescriptionTextColor: White, - selectedDescriptionBGColor: Cyan, - scrollbarThumbColor: DarkGray, - scrollbarBGColor: Cyan, - col: 1, + r := NewRenderer() + r.out = &PosixWriter{ + fd: syscall.Stdin, // "write" to stdin just so we don't mess with the output of the tests } + r.col = 1 b := NewBuffer() r.BreakLine(b, nil) @@ -115,3 +97,45 @@ func TestBreakLineCallback(t *testing.T) { t.Errorf("BreakLine callback not called, i should be 3") } } + +func TestGetMultilinePrefix(t *testing.T) { + tests := map[string]struct { + prefix string + want string + }{ + "single width chars": { + prefix: ">>", + want: "..", + }, + "double width chars": { + prefix: "本日", + want: "....", + }, + "trailing spaces and single width chars": { + prefix: ">!> ", + want: "... ", + }, + "trailing spaces and double width chars": { + prefix: "本日: ", + want: "..... ", + }, + "leading spaces and single width chars": { + prefix: " ah: ", + want: "..... ", + }, + "leading spaces and double width chars": { + prefix: " 本日: ", + want: "....... ", + }, + } + + r := NewRenderer() + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + got := r.getMultilinePrefix(tc.prefix) + if tc.want != got { + t.Errorf("Expected %#v, but got %#v", tc.want, got) + } + }) + } +} diff --git a/strings/strings.go b/strings/strings.go index 3406ca82..8523faf8 100644 --- a/strings/strings.go +++ b/strings/strings.go @@ -21,6 +21,11 @@ func GetWidth(s string) Width { return Width(runewidth.StringWidth(s)) } +// Get the width of the rune (how many columns it takes upt in the terminal). +func GetRuneWidth(char rune) Width { + return Width(runewidth.RuneWidth(char)) +} + // IndexNotByte is similar with strings.IndexByte but showing the opposite behavior. func IndexNotByte(s string, c byte) ByteNumber { n := len(s)