diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0e71cb7e..d279c86c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -15,7 +15,7 @@ jobs: go: ['1.19', '1.20'] steps: - name: Set up Go - uses: actions/setup-go@v2 + uses: actions/setup-go@v4 with: go-version: ${{ matrix.go }} id: go @@ -31,7 +31,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Set up Go - uses: actions/setup-go@v2 + uses: actions/setup-go@v4 with: go-version: '1.20' id: go diff --git a/.gitignore b/.gitignore index eba2b56a..a45a8758 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,7 @@ _testmain.go # Glide vendor/ + +# Logs +log +*.log diff --git a/CHANGELOG.md b/CHANGELOG.md index b9e21f1e..816fa3ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,79 +1,192 @@ -# Change Log +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + + +## [1.0.0] + +[Diff](https://github.com/elk-language/go-prompt/compare/v0.2.6...elk-language:go-prompt:v1.0.0) + +This release contains a major refactoring of the codebase. +It's the first release of the [elk-language/go-prompt](https://github.com/elk-language/go-prompt) fork. + +The original library has been abandoned for at least 2 years now (although serious development has stopped 5 years ago). + +This release aims to make the code a bit cleaner, fix a couple of bugs and provide new, essential functionality such as syntax highlighting, dynamic Enter and multiline edit support. + +### Added + +- `prompt.New` constructor options: + - `prompt.WithLexer` let's you set a custom lexer for providing syntax highlighting + - `prompt.WithCompleter` for setting a custom `Completer` (completer is no longer a required argument in `prompt.New`) + - `prompt.WithIndentSize` let's you customise how many spaces should constitute a single indentation level + - `prompt.WithExecuteOnEnterCallback` + +- `prompt.Position` -- represents the cursor's position in 2D +- `prompt.Lexer`, `prompt.Token`, `prompt.SimpleToken`, `prompt.EagerLexer`, `prompt.LexerFunc` -- new syntax highlighting functionality +- `prompt.ExecuteOnEnterCallback` -- new dynamic Enter functionality (decide whether to insert a newline and indent or execute the input) + +- examples: + - `_example/bang-executor` -- a sample program which uses the new `ExecuteOnEnterCallback`. Pressing Enter will insert a newline unless the input ends with an exclamation point `!` (then it gets executed). + - `_example/even-lexer` -- a sample program which shows how to use the new lexer feature. It implements a simple lexer which colours every character with an even index green. + +### Changed + +- Update Go from 1.16 to 1.19 +- The cursor can move in 2D (left-right, up-down) +- The Up arrow key will jump to the line above if the cursor is beyond the first line, but it will replace the input with the previous history entry if it's on the first line (like in Ruby's irb) +- The Down arrow key will jump to the line below if the cursor is before the last line, but it will replace the input with the next history entry if it's on the last line (like in Ruby's irb) +- 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`. +- 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` +- Rename `prompt.OptionPrefix` to `prompt.WithPrefix` +- Rename `prompt.OptionInitialBufferText` to `prompt.WithInitialText` +- Rename `prompt.OptionCompletionWordSeparator` to `prompt.WithCompletionWordSeparator` +- Replace `prompt.OptionLivePrefix` with `prompt.WithPrefixCallback` -- `func() string`. The prefix is always determined by a callback function which should always return a `string`. +- Rename `prompt.OptionPrefixTextColor` to `prompt.WithPrefixTextColor` +- 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` +- Rename `prompt.OptionSelectedSuggestionBGColor` to `prompt.WithSelectedSuggestionBGColor` +- Rename `prompt.OptionDescriptionTextColor` to `prompt.WithDescriptionTextColor` +- Rename `prompt.OptionDescriptionBGColor` to `prompt.WithDescriptionBGColor` +- Rename `prompt.OptionSelectedDescriptionTextColor` to `prompt.WithSelectedDescriptionTextColor` +- Rename `prompt.OptionSelectedDescriptionBGColor` to `prompt.WithSelectedDescriptionBGColor` +- Rename `prompt.OptionScrollbarThumbColor` to `prompt.WithScrollbarThumbColor` +- Rename `prompt.OptionScrollbarBGColor` to `prompt.WithScrollbarBGColor` +- Rename `prompt.OptionMaxSuggestion` to `prompt.WithMaxSuggestion` +- Rename `prompt.OptionHistory` to `prompt.WithHistory` +- Rename `prompt.OptionSwitchKeyBindMode` to `prompt.WithKeyBindMode` +- Rename `prompt.OptionCompletionOnDown` to `prompt.WithCompletionOnDown` +- Rename `prompt.OptionAddKeyBind` to `prompt.WithKeyBind` +- Rename `prompt.OptionAddASCIICodeBind` to `prompt.WithASCIICodeBind` +- Rename `prompt.OptionShowCompletionAtStart` to `prompt.WithShowCompletionAtStart` +- Rename `prompt.OptionBreakLineCallback` to `prompt.WithBreakLineCallback` +- Rename `prompt.OptionExitChecker` to `prompt.WithExitChecker` -## v0.3.0 (2018/??/??) +### Fixed + +- 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. +- Remove a `/dev/tty` leak in `prompt.PosixReader` (old `prompt.PosixParser`) + +### Removed + +- `prompt.SwitchKeyBindMode` + + +## [0.2.6] - 2021-03-03 + +[Diff](https://github.com/elk-language/go-prompt/compare/v0.2.5...elk-language:go-prompt:v0.2.6) + +### Changed + +- Update pkg/term to 1.2.0 + + +## [0.2.5] - 2020-09-19 + +[Diff](https://github.com/elk-language/go-prompt/compare/v0.2.4...elk-language:go-prompt:v0.2.5) + +### Changed + +- Upgrade all dependencies to latest -next release. -## v0.2.3 (2018/10/25) +## [0.2.4] - 2020-09-18 -### What's new? +[Diff](https://github.com/elk-language/go-prompt/compare/v0.2.3...elk-language:go-prompt:v0.2.4) -* Add `prompt.FuzzyFilter` for fuzzy matching at [#92](https://github.com/c-bata/go-prompt/pull/92). -* Add `OptionShowCompletionAtStart` to show completion at start at [#100](https://github.com/c-bata/go-prompt/pull/100). -* Add `prompt.NewStderrWriter` at [#102](https://github.com/c-bata/go-prompt/pull/102). +### Changed + +- Update pkg/term module to latest and use unix.Termios + + +## [0.2.3] - 2018-10-25 + +[Diff](https://github.com/elk-language/go-prompt/compare/v0.2.2...elk-language:go-prompt:v0.2.3) + +### Added + +* `prompt.FuzzyFilter` for fuzzy matching at [#92](https://github.com/c-bata/go-prompt/pull/92). +* `OptionShowCompletionAtStart` to show completion at start at [#100](https://github.com/c-bata/go-prompt/pull/100). +* `prompt.NewStderrWriter` at [#102](https://github.com/c-bata/go-prompt/pull/102). ### Fixed -* Fix resetting display attributes (please see [pull #104](https://github.com/c-bata/go-prompt/pull/104) for more details). -* Fix error handling of Flush function in ConsoleWriter (please see [pull #97](https://github.com/c-bata/go-prompt/pull/97) for more details). -* Fix panic problem when reading from stdin before starting the prompt (please see [issue #88](https://github.com/c-bata/go-prompt/issues/88) for more details). +* reset display attributes (please see [pull #104](https://github.com/c-bata/go-prompt/pull/104) for more details). +* handle errors of Flush function in ConsoleWriter (please see [pull #97](https://github.com/c-bata/go-prompt/pull/97) for more details). +* don't panic problem when reading from stdin before starting the prompt (please see [issue #88](https://github.com/c-bata/go-prompt/issues/88) for more details). + +### Deprecated -### Removed or Deprecated +* `prompt.NewStandardOutputWriter` -- please use `prompt.NewStdoutWriter`. -* `prompt.NewStandardOutputWriter` is deprecated. Please use `prompt.NewStdoutWriter`. -## v0.2.2 (2018/06/28) +## [0.2.2] - 2018-06-28 -### What's new? +[Diff](https://github.com/elk-language/go-prompt/compare/v0.2.1...elk-language:go-prompt:v0.2.2) -* Support CJK(Chinese, Japanese and Korean) and Cyrillic characters. -* Add OptionCompletionWordSeparator(x string) to customize insertion points for completions. - * To support this, text query functions by arbitrary word separator are added in Document (please see [here](https://github.com/c-bata/go-prompt/pull/79) for more details). -* Add FilePathCompleter to complete file path on your system. -* Add option to customize ascii code key bindings. -* Add GetWordAfterCursor method in Document. +### Added -### Removed or Deprecated +* Support CJK (Chinese, Japanese and Korean) and Cyrillic characters. +* `OptionCompletionWordSeparator(x string)` to customize insertion points for completions. + * To support this, text query functions by arbitrary word separator are added in `Document` (please see [here](https://github.com/c-bata/go-prompt/pull/79) for more details). +* `FilePathCompleter` to complete file path on your system. +* `option` to customize ascii code key bindings. +* `GetWordAfterCursor` method in `Document`. -* prompt.Choose shortcut function is deprecated. +### Deprecated -## v0.2.1 (2018/02/14) +* `prompt.Choose` shortcut function is deprecated. -### What's New? + +## [0.2.1] - 2018-02-14 + +[Diff](https://github.com/elk-language/go-prompt/compare/v0.2.0...elk-language:go-prompt:v0.2.1) + +### Added * ~~It seems that windows support is almost perfect.~~ * A critical bug is found :( When you change a terminal window size, the layout will be broken because current implementation cannot catch signal for updating window size on Windows. ### Fixed -* Fix a Shift+Tab handling on Windows. -* Fix 4-dimension arrow keys handling on Windows. +* Shift + Tab handling on Windows. +* 4-dimension arrow keys handling on Windows. -## v0.2.0 (2018/02/13) -### What's New? +## [0.2.0] - 2018-02-13 -* Supports scrollbar when there are too many matched suggestions -* Windows support (but please caution because this is still not perfect). -* Add OptionLivePrefix to update the prefix dynamically -* Implement clear screen by `Ctrl+L`. +[Diff](https://github.com/elk-language/go-prompt/compare/v0.1.0...elk-language:go-prompt:v0.2.0) -### Fixed - -* Fix the behavior of `Ctrl+W` keybind. -* Fix the panic because when running on a docker container (please see [here](https://github.com/c-bata/go-prompt/pull/32) for details). -* Fix panic when making terminal window small size after input 2 lines of texts. See [here](https://github.com/c-bata/go-prompt/issues/37) for details). -* And also fixed many bugs that layout is broken when using Terminal.app, GNU Terminal and a Goland(IntelliJ). +### Added -### News +* Support scrollbar when there are too many matched suggestions +* Support Windows (but please caution because this is still not perfect). +* `OptionLivePrefix` to update the prefix dynamically +* Clear screen by Ctrl + L. -New core developers are joined (alphabetical order). +### Fixed -* Nao Yonashiro (Github @orisano) -* Ryoma Abe (Github @Allajah) -* Yusuke Nakamura (Github @unasuke) +* Improve the Ctrl + W keybind. +* Don't panic because when running in a docker container (please see [here](https://github.com/c-bata/go-prompt/pull/32) for details). +* Don't panic when making terminal window small size after input 2 lines of texts. See [here](https://github.com/c-bata/go-prompt/issues/37) for details). +* Get rid of many bugs that layout is broken when using Terminal.app, GNU Terminal and a Goland(IntelliJ). -## v0.1.0 (2017/08/15) +## [0.1.0] - 2017-08-15 Initial Release diff --git a/_example/README.md b/_example/README.md index 759b0e3f..02fe6057 100644 --- a/_example/README.md +++ b/_example/README.md @@ -3,6 +3,18 @@ This directory includes some examples using go-prompt. These examples are useful to know the usage of go-prompt and check behavior for development. +## even-lexer + +Uses a custom lexer that colours every character with an even index green. + +Shows you how to hook up a custom lexer for syntax highlighting. + +## bang-executor + +Inserts a newline when the Enter key is pressed unless the input ends with an exclamation point `!` (then it gets printed). + +Shows you how to define a custom callback which determines whether the input is complete and should be executed or a newline should be inserted (after Enter has been pressed). + ## simple-echo ![simple-input](https://github.com/c-bata/assets/raw/master/go-prompt/examples/input.gif) @@ -19,7 +31,7 @@ A simple [http-prompt](https://github.com/eliangcs/http-prompt) implementation u ![live-prefix](https://github.com/c-bata/assets/raw/master/go-prompt/examples/live-prefix.gif) -A example application which changes a prefix string dynamically. +A example application which changes the prefix string dynamically. This feature is used like [ktr0731/evans](https://github.com/ktr0731/evans) which is interactive gRPC client using go-prompt. ## exec-command diff --git a/_example/bang-executor/main.go b/_example/bang-executor/main.go new file mode 100644 index 00000000..786ef551 --- /dev/null +++ b/_example/bang-executor/main.go @@ -0,0 +1,26 @@ +package main + +import ( + "fmt" + "unicode/utf8" + + "github.com/elk-language/go-prompt" +) + +func main() { + p := prompt.New( + executor, + prompt.WithExecuteOnEnterCallback(ExecuteOnEnter), + ) + + p.Run() +} + +func ExecuteOnEnter(input string, indentSize int) (int, bool) { + char, _ := utf8.DecodeLastRuneInString(input) + return 1, char == '!' +} + +func executor(s string) { + fmt.Println("You printed: " + s) +} diff --git a/_example/build.sh b/_example/build.sh index 7b71a4f5..8c561684 100755 --- a/_example/build.sh +++ b/_example/build.sh @@ -11,3 +11,4 @@ go build -o ${BIN_DIR}/live-prefix ${DIR}/live-prefix/main.go go build -o ${BIN_DIR}/simple-echo ${DIR}/simple-echo/main.go go build -o ${BIN_DIR}/simple-echo-cjk-cyrillic ${DIR}/simple-echo/cjk-cyrillic/main.go go build -o ${BIN_DIR}/even-lexer ${DIR}/even-lexer/main.go +go build -o ${BIN_DIR}/bang-executor ${DIR}/bang-executor/main.go diff --git a/_example/even-lexer/main.go b/_example/even-lexer/main.go index ea5f3375..ac82b5f4 100644 --- a/_example/even-lexer/main.go +++ b/_example/even-lexer/main.go @@ -10,8 +10,7 @@ import ( func main() { p := prompt.New( executor, - completer, - prompt.OptionSetLexer(prompt.NewEagerLexer(lexer)), + prompt.WithLexer(prompt.NewEagerLexer(lexer)), ) p.Run() @@ -38,10 +37,6 @@ func lexer(line string) []prompt.Token { return elements } -func completer(in prompt.Document) []prompt.Suggest { - return []prompt.Suggest{} -} - func executor(s string) { fmt.Println("You printed: " + s) } diff --git a/_example/exec-command/main.go b/_example/exec-command/main.go index 58de271d..02766bf7 100644 --- a/_example/exec-command/main.go +++ b/_example/exec-command/main.go @@ -8,14 +8,15 @@ import ( ) func executor(t string) { - if t == "bash" { - cmd := exec.Command("bash") - cmd.Stdin = os.Stdin - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - cmd.Run() + if t != "bash" { + return } - return + + cmd := exec.Command("bash") + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Run() } func completer(t prompt.Document) []prompt.Suggest { @@ -27,7 +28,7 @@ func completer(t prompt.Document) []prompt.Suggest { func main() { p := prompt.New( executor, - completer, + prompt.WithCompleter(completer), ) p.Run() } diff --git a/_example/http-prompt/main.go b/_example/http-prompt/main.go index 2824a22e..d80e0a25 100644 --- a/_example/http-prompt/main.go +++ b/_example/http-prompt/main.go @@ -92,11 +92,13 @@ var suggestions = []prompt.Suggest{ {"X-XSRF-TOKEN", "Prevent cross-site request forgery"}, } -func livePrefix() (string, bool) { - if ctx.url.Path == "/" { - return "", false +func livePrefix(defaultPrefix string) prompt.PrefixCallback { + return func() string { + if ctx.url.Path == "/" { + return defaultPrefix + } + return ctx.url.String() + "> " } - return ctx.url.String() + "> ", true } func executor(in string) { @@ -183,10 +185,9 @@ func main() { p := prompt.New( executor, - completer, - prompt.OptionPrefix(u.String()+"> "), - prompt.OptionLivePrefix(livePrefix), - prompt.OptionTitle("http-prompt"), + prompt.WithPrefixCallback(livePrefix(u.String()+"> ")), + prompt.WithTitle("http-prompt"), + prompt.WithCompleter(completer), ) p.Run() } diff --git a/_example/live-prefix/main.go b/_example/live-prefix/main.go index 8a457dfa..4fe160a5 100644 --- a/_example/live-prefix/main.go +++ b/_example/live-prefix/main.go @@ -6,20 +6,15 @@ import ( prompt "github.com/elk-language/go-prompt" ) -var LivePrefixState struct { - LivePrefix string - IsEnable bool -} +var LivePrefix string = ">>> " func executor(in string) { fmt.Println("Your input: " + in) if in == "" { - LivePrefixState.IsEnable = false - LivePrefixState.LivePrefix = in + LivePrefix = ">>> " return } - LivePrefixState.LivePrefix = in + "> " - LivePrefixState.IsEnable = true + LivePrefix = in + "> " } func completer(in prompt.Document) []prompt.Suggest { @@ -32,17 +27,16 @@ func completer(in prompt.Document) []prompt.Suggest { return prompt.FilterHasPrefix(s, in.GetWordBeforeCursor(), true) } -func changeLivePrefix() (string, bool) { - return LivePrefixState.LivePrefix, LivePrefixState.IsEnable +func changeLivePrefix() string { + return LivePrefix } func main() { p := prompt.New( executor, - completer, - prompt.OptionPrefix(">>> "), - prompt.OptionLivePrefix(changeLivePrefix), - prompt.OptionTitle("live-prefix-example"), + prompt.WithPrefixCallback(changeLivePrefix), + prompt.WithTitle("live-prefix-example"), + prompt.WithCompleter(completer), ) p.Run() } diff --git a/_example/simple-echo/cjk-cyrillic/main.go b/_example/simple-echo/cjk-cyrillic/main.go index 27309723..27f65248 100644 --- a/_example/simple-echo/cjk-cyrillic/main.go +++ b/_example/simple-echo/cjk-cyrillic/main.go @@ -23,9 +23,9 @@ func completer(in prompt.Document) []prompt.Suggest { func main() { p := prompt.New( executor, - completer, - prompt.OptionPrefix(">>> "), - prompt.OptionTitle("sql-prompt for multi width characters"), + prompt.WithCompleter(completer), + prompt.WithPrefix(">>> "), + prompt.WithTitle("sql-prompt for multi width characters"), ) p.Run() } diff --git a/_example/simple-echo/main.go b/_example/simple-echo/main.go index 3330c0fc..62be416e 100644 --- a/_example/simple-echo/main.go +++ b/_example/simple-echo/main.go @@ -17,12 +17,15 @@ func completer(in prompt.Document) []prompt.Suggest { } func main() { - in := prompt.Input(">>> ", completer, - prompt.OptionTitle("sql-prompt"), - prompt.OptionHistory([]string{"SELECT * FROM users;"}), - prompt.OptionPrefixTextColor(prompt.Yellow), - prompt.OptionPreviewSuggestionTextColor(prompt.Blue), - prompt.OptionSelectedSuggestionBGColor(prompt.LightGray), - prompt.OptionSuggestionBGColor(prompt.DarkGray)) + in := prompt.Input( + prompt.WithPrefix(">>> "), + prompt.WithTitle("sql-prompt"), + prompt.WithHistory([]string{"SELECT * FROM users;"}), + prompt.WithPrefixTextColor(prompt.Yellow), + prompt.WithPreviewSuggestionTextColor(prompt.Blue), + prompt.WithSelectedSuggestionBGColor(prompt.LightGray), + prompt.WithSuggestionBGColor(prompt.DarkGray), + prompt.WithCompleter(completer), + ) fmt.Println("Your input: " + in) } diff --git a/_tools/complete_file/main.go b/_tools/complete_file/main.go index 0d7cb9fe..7992607f 100644 --- a/_tools/complete_file/main.go +++ b/_tools/complete_file/main.go @@ -36,8 +36,8 @@ func main() { p := prompt.New( executor, completerFunc, - prompt.OptionPrefix(">>> "), - prompt.OptionCompletionWordSeparator(completer.FilePathCompletionSeparator), + prompt.WithPrefix(">>> "), + prompt.WithCompletionWordSeparator(completer.FilePathCompletionSeparator), ) p.Run() } diff --git a/_tools/vt100_debug/main.go b/_tools/vt100_debug/main.go index 9dc6a483..3b46ce7c 100644 --- a/_tools/vt100_debug/main.go +++ b/_tools/vt100_debug/main.go @@ -8,7 +8,7 @@ import ( "syscall" prompt "github.com/elk-language/go-prompt" - "github.com/elk-language/go-prompt/internal/term" + "github.com/elk-language/go-prompt/term" ) func main() { diff --git a/internal/bisect/bisect.go b/bisect/bisect.go similarity index 55% rename from internal/bisect/bisect.go rename to bisect/bisect.go index efe162fa..e8f25d74 100644 --- a/internal/bisect/bisect.go +++ b/bisect/bisect.go @@ -3,13 +3,13 @@ package bisect import "sort" // Right to locate the insertion point for v in a to maintain sorted order. -func Right(a []int, v int) int { +func Right[T ~int](a []T, v T) T { return bisectRightRange(a, v, 0, len(a)) } -func bisectRightRange(a []int, v int, lo, hi int) int { +func bisectRightRange[T ~int](a []T, v T, lo, hi int) T { s := a[lo:hi] - return sort.Search(len(s), func(i int) bool { + return T(sort.Search(len(s), func(i int) bool { return s[i] > v - }) + })) } diff --git a/internal/bisect/bisect_test.go b/bisect/bisect_test.go similarity index 95% rename from internal/bisect/bisect_test.go rename to bisect/bisect_test.go index 51a62452..332921bf 100644 --- a/internal/bisect/bisect_test.go +++ b/bisect/bisect_test.go @@ -5,7 +5,7 @@ import ( "math/rand" "testing" - "github.com/elk-language/go-prompt/internal/bisect" + "github.com/elk-language/go-prompt/bisect" ) func Example() { diff --git a/buffer.go b/buffer.go index 996af7c3..a1d44e6e 100644 --- a/buffer.go +++ b/buffer.go @@ -3,17 +3,17 @@ package prompt import ( "strings" - "github.com/elk-language/go-prompt/internal/debug" + "github.com/elk-language/go-prompt/debug" + istrings "github.com/elk-language/go-prompt/strings" ) // Buffer emulates the console buffer. type Buffer struct { - workingLines []string // The working lines. Similar to history - workingIndex int - cursorPosition int - cacheDocument *Document - preferredColumn int // Remember the original column for the next up/down movement. - lastKeyStroke Key + workingLines []string // The working lines. Similar to history + workingIndex int // index of the current line + cursorPosition istrings.RuneNumber + cacheDocument *Document + lastKeyStroke Key } // Text returns string of the current line. @@ -37,44 +37,43 @@ 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() int { - return b.Document().DisplayCursorPosition() +func (b *Buffer) DisplayCursorPosition(columns istrings.StringWidth) Position { + return b.Document().DisplayCursorPosition(columns) } // InsertText insert string from current line. -func (b *Buffer) InsertText(v string, overwrite bool, moveCursor bool) { - or := []rune(b.Text()) - oc := b.cursorPosition +func (b *Buffer) InsertText(text string, overwrite bool, moveCursor bool) { + currentTextRunes := []rune(b.Text()) + cursor := b.cursorPosition if overwrite { - overwritten := string(or[oc:]) - if len(overwritten) >= oc+len(v) { - overwritten = string(or[oc : oc+len(v)]) + overwritten := string(currentTextRunes[cursor:]) + if len(overwritten) >= int(cursor)+len(text) { + overwritten = string(currentTextRunes[cursor : cursor+istrings.RuneCount(text)]) } - if strings.Contains(overwritten, "\n") { - i := strings.IndexAny(overwritten, "\n") + if i := strings.IndexAny(overwritten, "\n"); i != -1 { overwritten = overwritten[:i] } - b.setText(string(or[:oc]) + v + string(or[oc+len(overwritten):])) + b.setText(string(currentTextRunes[:cursor]) + text + string(currentTextRunes[cursor+istrings.RuneCount(overwritten):])) } else { - b.setText(string(or[:oc]) + v + string(or[oc:])) + b.setText(string(currentTextRunes[:cursor]) + text + string(currentTextRunes[cursor:])) } if moveCursor { - b.cursorPosition += len([]rune(v)) + b.cursorPosition += istrings.RuneCount(text) } } // 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(v string) { - debug.Assert(b.cursorPosition <= len([]rune(v)), "length of input should be shorter than cursor position") - b.workingLines[b.workingIndex] = v +func (b *Buffer) setText(text string) { + debug.Assert(b.cursorPosition <= istrings.RuneCount(text), "length of input should be shorter than cursor position") + b.workingLines[b.workingIndex] = text } // Set cursor position. Return whether it changed. -func (b *Buffer) setCursorPosition(p int) { +func (b *Buffer) setCursorPosition(p istrings.RuneNumber) { if p > 0 { b.cursorPosition = p } else { @@ -89,13 +88,13 @@ func (b *Buffer) setDocument(d *Document) { } // CursorLeft move to left on the current line. -func (b *Buffer) CursorLeft(count int) { +func (b *Buffer) CursorLeft(count istrings.RuneNumber) { l := b.Document().GetCursorLeftPosition(count) b.cursorPosition += l } // CursorRight move to right on the current line. -func (b *Buffer) CursorRight(count int) { +func (b *Buffer) CursorRight(count istrings.RuneNumber) { l := b.Document().GetCursorRightPosition(count) b.cursorPosition += l } @@ -103,31 +102,19 @@ func (b *Buffer) CursorRight(count int) { // CursorUp move cursor to the previous line. // (for multi-line edit). func (b *Buffer) CursorUp(count int) { - orig := b.preferredColumn - if b.preferredColumn == -1 { // -1 means nil - orig = b.Document().CursorPositionCol() - } + orig := b.Document().CursorPositionCol() b.cursorPosition += b.Document().GetCursorUpPosition(count, orig) - - // Remember the original column for the next up/down movement. - b.preferredColumn = orig } // CursorDown move cursor to the next line. // (for multi-line edit). func (b *Buffer) CursorDown(count int) { - orig := b.preferredColumn - if b.preferredColumn == -1 { // -1 means nil - orig = b.Document().CursorPositionCol() - } + orig := b.Document().CursorPositionCol() b.cursorPosition += b.Document().GetCursorDownPosition(count, orig) - - // Remember the original column for the next up/down movement. - b.preferredColumn = orig } // DeleteBeforeCursor delete specified number of characters before cursor and return the deleted text. -func (b *Buffer) DeleteBeforeCursor(count int) (deleted string) { +func (b *Buffer) DeleteBeforeCursor(count istrings.RuneNumber) (deleted string) { debug.Assert(count >= 0, "count should be positive") r := []rune(b.Text()) @@ -139,7 +126,7 @@ func (b *Buffer) DeleteBeforeCursor(count int) (deleted string) { deleted = string(r[start:b.cursorPosition]) b.setDocument(&Document{ Text: string(r[:start]) + string(r[b.cursorPosition:]), - cursorPosition: b.cursorPosition - len([]rune(deleted)), + cursorPosition: b.cursorPosition - istrings.RuneNumber(len([]rune(deleted))), }) } return @@ -155,13 +142,19 @@ func (b *Buffer) NewLine(copyMargin bool) { } // Delete specified number of characters and Return the deleted text. -func (b *Buffer) Delete(count int) (deleted string) { +func (b *Buffer) Delete(count istrings.RuneNumber) string { r := []rune(b.Text()) - if b.cursorPosition < len(r) { - deleted = b.Document().TextAfterCursor()[:count] - b.setText(string(r[:b.cursorPosition]) + string(r[b.cursorPosition+len(deleted):])) + 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)):])) + + deleted := string(deletedRunes) + return deleted } - return + + return "" } // JoinNextLine joins the next line to the current one by deleting the line ending after the current line. @@ -186,9 +179,8 @@ func (b *Buffer) SwapCharactersBeforeCursor() { // NewBuffer is constructor of Buffer struct. func NewBuffer() (b *Buffer) { b = &Buffer{ - workingLines: []string{""}, - workingIndex: 0, - preferredColumn: -1, // -1 means nil + workingLines: []string{""}, + workingIndex: 0, } return } diff --git a/buffer_test.go b/buffer_test.go index e6859f13..644a5be7 100644 --- a/buffer_test.go +++ b/buffer_test.go @@ -3,6 +3,8 @@ package prompt import ( "reflect" "testing" + + istrings "github.com/elk-language/go-prompt/strings" ) func TestNewBuffer(t *testing.T) { @@ -23,8 +25,8 @@ func TestBuffer_InsertText(t *testing.T) { t.Errorf("Text should be %#v, got %#v", "some_text", b.Text()) } - if b.cursorPosition != len("some_text") { - t.Errorf("cursorPosition should be %#v, got %#v", len("some_text"), b.cursorPosition) + if b.cursorPosition != istrings.RuneCount("some_text") { + t.Errorf("cursorPosition should be %#v, got %#v", istrings.RuneCount("some_text"), b.cursorPosition) } } @@ -36,8 +38,8 @@ func TestBuffer_InsertText_Overwrite(t *testing.T) { t.Errorf("Text should be %#v, got %#v", "ABC", b.Text()) } - if b.cursorPosition != len("ABC") { - t.Errorf("cursorPosition should be %#v, got %#v", len("ABC"), b.cursorPosition) + if b.cursorPosition != istrings.RuneCount("ABC") { + t.Errorf("cursorPosition should be %#v, got %#v", istrings.RuneCount("ABC"), b.cursorPosition) } b.CursorLeft(1) @@ -85,8 +87,8 @@ func TestBuffer_CursorMovement(t *testing.T) { if b.Text() != "some_teAxt" { t.Errorf("Text should be %#v, got %#v", "some_teAxt", b.Text()) } - if b.cursorPosition != len("some_teA") { - t.Errorf("Text should be %#v, got %#v", len("some_teA"), b.cursorPosition) + if b.cursorPosition != istrings.RuneCount("some_teA") { + t.Errorf("Text should be %#v, got %#v", istrings.RuneCount("some_teA"), b.cursorPosition) } // Moving over left character counts. @@ -95,8 +97,8 @@ func TestBuffer_CursorMovement(t *testing.T) { if b.Text() != "Asome_teAxt" { t.Errorf("Text should be %#v, got %#v", "some_teAxt", b.Text()) } - if b.cursorPosition != len("A") { - t.Errorf("Text should be %#v, got %#v", len("some_teA"), b.cursorPosition) + if b.cursorPosition != istrings.RuneCount("A") { + t.Errorf("Text should be %#v, got %#v", istrings.RuneCount("some_teA"), b.cursorPosition) } // TODO: Going right already at right end. @@ -109,6 +111,10 @@ func TestBuffer_CursorMovement_WithMultiByte(t *testing.T) { if l := b.Document().TextAfterCursor(); l != "お" { t.Errorf("Should be 'お', but got %s", l) } + b.InsertText("żółć", true, true) + if b.Text() != "あいうえżółć" { + t.Errorf("Text should be %#v, got %#v", "あいうえżółć", b.Text()) + } } func TestBuffer_CursorUp(t *testing.T) { @@ -141,8 +147,8 @@ func TestBuffer_CursorDown(t *testing.T) { // Normally going down b.CursorDown(1) - if b.Document().cursorPosition != len("line1\nlin") { - t.Errorf("Should be %#v, got %#v", len("line1\nlin"), b.Document().cursorPosition) + 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. @@ -150,8 +156,8 @@ func TestBuffer_CursorDown(t *testing.T) { b.InsertText("long line1\na\nb", false, true) b.cursorPosition = 3 b.CursorDown(1) - if b.Document().cursorPosition != len("long line1\na") { - t.Errorf("Should be %#v, got %#v", len("long line1\na"), b.Document().cursorPosition) + if b.Document().cursorPosition != istrings.RuneCount("long line1\na") { + t.Errorf("Should be %#v, got %#v", istrings.RuneCount("long line1\na"), b.Document().cursorPosition) } } @@ -167,8 +173,8 @@ func TestBuffer_DeleteBeforeCursor(t *testing.T) { if deleted != "e" { t.Errorf("Should be %#v, got %#v", deleted, "e") } - if b.cursorPosition != len("some_t") { - t.Errorf("Should be %#v, got %#v", len("some_t"), b.cursorPosition) + if b.cursorPosition != istrings.RuneCount("some_t") { + t.Errorf("Should be %#v, got %#v", istrings.RuneCount("some_t"), b.cursorPosition) } // Delete over the characters length before cursor. diff --git a/completer/file.go b/completer/file.go index 1b7a544a..d0862337 100644 --- a/completer/file.go +++ b/completer/file.go @@ -1,14 +1,13 @@ package completer import ( - "io/ioutil" "os" "os/user" "path/filepath" "runtime" prompt "github.com/elk-language/go-prompt" - "github.com/elk-language/go-prompt/internal/debug" + "github.com/elk-language/go-prompt/debug" ) var ( @@ -17,7 +16,7 @@ var ( ) // FilePathCompleter is a completer for your local file system. -// Please caution that you need to set OptionCompletionWordSeparator(completer.FilePathCompletionSeparator) +// Please caution that you need to set WithCompletionWordSeparator(completer.FilePathCompletionSeparator) // when you use this completer. type FilePathCompleter struct { Filter func(fi os.FileInfo) bool @@ -70,7 +69,7 @@ func (c *FilePathCompleter) Complete(d prompt.Document) []prompt.Suggest { return prompt.FilterHasPrefix(cached, base, c.IgnoreCase) } - files, err := ioutil.ReadDir(dir) + files, err := os.ReadDir(dir) if err != nil && os.IsNotExist(err) { return nil } else if err != nil { @@ -80,7 +79,11 @@ func (c *FilePathCompleter) Complete(d prompt.Document) []prompt.Suggest { suggests := make([]prompt.Suggest, 0, len(files)) for _, f := range files { - if c.Filter != nil && !c.Filter(f) { + fileInfo, err := f.Info() + if err != nil { + panic(err) + } + if c.Filter != nil && !c.Filter(fileInfo) { continue } suggests = append(suggests, prompt.Suggest{Text: f.Name()}) diff --git a/completion.go b/completion.go index 69183dbd..1ee6713c 100644 --- a/completion.go +++ b/completion.go @@ -3,7 +3,8 @@ package prompt import ( "strings" - "github.com/elk-language/go-prompt/internal/debug" + "github.com/elk-language/go-prompt/debug" + istrings "github.com/elk-language/go-prompt/strings" runewidth "github.com/mattn/go-runewidth" ) @@ -155,7 +156,7 @@ func formatTexts(o []string, max int, prefix, suffix string) (new []string, widt return n, lenPrefix + width + lenSuffix } -func formatSuggestions(suggests []Suggest, max int) (new []Suggest, width int) { +func formatSuggestions(suggests []Suggest, max int) (new []Suggest, width istrings.StringWidth) { num := len(suggests) new = make([]Suggest, num) @@ -177,18 +178,33 @@ func formatSuggestions(suggests []Suggest, max int) (new []Suggest, width int) { for i := 0; i < num; i++ { new[i] = Suggest{Text: left[i], Description: right[i]} } - return new, leftWidth + rightWidth + return new, istrings.StringWidth(leftWidth + rightWidth) } -// NewCompletionManager returns an initialized CompletionManager object. -func NewCompletionManager(completer Completer, max uint16) *CompletionManager { - return &CompletionManager{ - selected: -1, - max: max, - completer: completer, +// Constructor option for CompletionManager. +type CompletionManagerOption func(*CompletionManager) + +// Set a custom completer. +func CompletionManagerWithCompleter(completer Completer) CompletionManagerOption { + return func(c *CompletionManager) { + c.completer = completer + } +} +// NewCompletionManager returns an initialized CompletionManager object. +func NewCompletionManager(max uint16, opts ...CompletionManagerOption) *CompletionManager { + c := &CompletionManager{ + selected: -1, + max: max, + completer: NoopCompleter, verticalScroll: 0, } + + for _, opt := range opts { + opt(c) + } + + return c } var _ Completer = NoopCompleter diff --git a/completion_test.go b/completion_test.go index c17c1d91..c2d13446 100644 --- a/completion_test.go +++ b/completion_test.go @@ -3,6 +3,8 @@ package prompt import ( "reflect" "testing" + + istrings "github.com/elk-language/go-prompt/strings" ) func TestFormatShortSuggestion(t *testing.T) { @@ -10,7 +12,7 @@ func TestFormatShortSuggestion(t *testing.T) { in []Suggest expected []Suggest max int - exWidth int + exWidth istrings.StringWidth }{ { in: []Suggest{ @@ -38,7 +40,7 @@ func TestFormatShortSuggestion(t *testing.T) { {Text: " coconut ", Description: " This is coconut. "}, }, max: 100, - exWidth: len(" apple " + " This is apple. "), + exWidth: istrings.StringWidth(len(" apple " + " This is apple. ")), }, { in: []Suggest{ @@ -82,7 +84,7 @@ func TestFormatShortSuggestion(t *testing.T) { {Text: " --include-extended-apis ", Description: " --------------... "}, }, max: 50, - exWidth: len(" --include-extended-apis " + " ---------------..."), + exWidth: istrings.StringWidth(len(" --include-extended-apis " + " ---------------...")), }, { in: []Suggest{ @@ -102,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: len(" --include-extended-apis " + " If true, include definitions of new APIs via calls to the API server. [default true] "), + exWidth: istrings.StringWidth(len(" --include-extended-apis " + " If true, include definitions of new APIs via calls to the API server. [default true] ")), }, } diff --git a/constructor.go b/constructor.go new file mode 100644 index 00000000..ecb18a43 --- /dev/null +++ b/constructor.go @@ -0,0 +1,355 @@ +package prompt + +// Option is the type to replace default parameters. +// prompt.New accepts any number of options (this is functional option pattern). +type Option func(prompt *Prompt) error + +// Callback function that returns a prompt prefix. +type PrefixCallback func() (prefix string) + +const DefaultIndentSize = 2 + +// WithIndentSize is an option that sets the amount of spaces +// that constitute a single indentation level. +func WithIndentSize(i int) Option { + return func(p *Prompt) error { + p.indentSize = i + return nil + } +} + +// WithLexer set lexer function and enable it. +func WithLexer(lex Lexer) Option { + return func(p *Prompt) error { + p.lexer = lex + return nil + } +} + +// WithExecuteOnEnterCallback can be used to set +// a custom callback function that determines whether an Enter key +// should trigger the Executor or add a newline to the user input buffer. +func WithExecuteOnEnterCallback(fn ExecuteOnEnterCallback) Option { + return func(p *Prompt) error { + p.executeOnEnterCallback = fn + return nil + } +} + +// WithCompleter is an option that sets a custom Completer object. +func WithCompleter(c Completer) Option { + return func(p *Prompt) error { + p.completion.completer = c + return nil + } +} + +// WithReader can be used to set a custom Reader object. +func WithReader(r Reader) Option { + return func(p *Prompt) error { + p.reader = r + return nil + } +} + +// WithWriter can be used to set a custom Writer object. +func WithWriter(w Writer) Option { + return func(p *Prompt) error { + registerWriter(w) + p.renderer.out = w + return nil + } +} + +// WithTitle can be used to set the title displayed at the header bar of the terminal. +func WithTitle(t string) Option { + return func(p *Prompt) error { + p.renderer.title = t + return nil + } +} + +// WithPrefix can be used to set a prefix string for the prompt. +func WithPrefix(prefix string) Option { + return func(p *Prompt) error { + p.renderer.prefixCallback = func() string { return prefix } + return nil + } +} + +// 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) + return nil + } +} + +// WithCompletionWordSeparator can be used to set word separators. Enable only ' ' if empty. +func WithCompletionWordSeparator(sep string) Option { + return func(p *Prompt) error { + p.completion.wordSeparator = sep + return nil + } +} + +// WithPrefixCallback can be used to change the prefix dynamically by a callback function. +func WithPrefixCallback(f PrefixCallback) Option { + return func(p *Prompt) error { + p.renderer.prefixCallback = f + return nil + } +} + +// WithPrefixTextColor change a text color of prefix string +func WithPrefixTextColor(x Color) Option { + return func(p *Prompt) error { + p.renderer.prefixTextColor = x + return nil + } +} + +// WithPrefixBackgroundColor to change a background color of prefix string +func WithPrefixBackgroundColor(x Color) Option { + return func(p *Prompt) error { + p.renderer.prefixBGColor = x + return nil + } +} + +// WithInputTextColor to change a color of text which is input by user +func WithInputTextColor(x Color) Option { + return func(p *Prompt) error { + p.renderer.inputTextColor = x + return nil + } +} + +// WithInputBGColor to change a color of background which is input by user +func WithInputBGColor(x Color) Option { + return func(p *Prompt) error { + p.renderer.inputBGColor = x + return nil + } +} + +// 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 { + p.renderer.suggestionTextColor = x + return nil + } +} + +// WithSuggestionBGColor change a background color in drop down suggestions. +func WithSuggestionBGColor(x Color) Option { + return func(p *Prompt) error { + p.renderer.suggestionBGColor = x + return nil + } +} + +// WithSelectedSuggestionTextColor to change a text color for completed text which is selected inside suggestions drop down box. +func WithSelectedSuggestionTextColor(x Color) Option { + return func(p *Prompt) error { + p.renderer.selectedSuggestionTextColor = x + return nil + } +} + +// WithSelectedSuggestionBGColor to change a background color for completed text which is selected inside suggestions drop down box. +func WithSelectedSuggestionBGColor(x Color) Option { + return func(p *Prompt) error { + p.renderer.selectedSuggestionBGColor = x + return nil + } +} + +// WithDescriptionTextColor to change a background color of description text in drop down suggestions. +func WithDescriptionTextColor(x Color) Option { + return func(p *Prompt) error { + p.renderer.descriptionTextColor = x + return nil + } +} + +// WithDescriptionBGColor to change a background color of description text in drop down suggestions. +func WithDescriptionBGColor(x Color) Option { + return func(p *Prompt) error { + p.renderer.descriptionBGColor = x + return nil + } +} + +// WithSelectedDescriptionTextColor to change a text color of description which is selected inside suggestions drop down box. +func WithSelectedDescriptionTextColor(x Color) Option { + return func(p *Prompt) error { + p.renderer.selectedDescriptionTextColor = x + return nil + } +} + +// WithSelectedDescriptionBGColor to change a background color of description which is selected inside suggestions drop down box. +func WithSelectedDescriptionBGColor(x Color) Option { + return func(p *Prompt) error { + p.renderer.selectedDescriptionBGColor = x + return nil + } +} + +// WithScrollbarThumbColor to change a thumb color on scrollbar. +func WithScrollbarThumbColor(x Color) Option { + return func(p *Prompt) error { + p.renderer.scrollbarThumbColor = x + return nil + } +} + +// WithScrollbarBGColor to change a background color of scrollbar. +func WithScrollbarBGColor(x Color) Option { + return func(p *Prompt) error { + p.renderer.scrollbarBGColor = x + return nil + } +} + +// WithMaxSuggestion specify the max number of displayed suggestions. +func WithMaxSuggestion(x uint16) Option { + return func(p *Prompt) error { + p.completion.max = x + return nil + } +} + +// WithHistory to set history expressed by string array. +func WithHistory(x []string) Option { + return func(p *Prompt) error { + p.history.histories = x + p.history.Clear() + return nil + } +} + +// WithKeyBindMode set a key bind mode. +func WithKeyBindMode(m KeyBindMode) Option { + return func(p *Prompt) error { + p.keyBindMode = m + return nil + } +} + +// WithCompletionOnDown allows for Down arrow key to trigger completion. +func WithCompletionOnDown() Option { + return func(p *Prompt) error { + p.completionOnDown = true + return nil + } +} + +// WithKeyBind to set a custom key bind. +func WithKeyBind(b ...KeyBind) Option { + return func(p *Prompt) error { + p.keyBindings = append(p.keyBindings, b...) + return nil + } +} + +// WithASCIICodeBind to set a custom key bind. +func WithASCIICodeBind(b ...ASCIICodeBind) Option { + return func(p *Prompt) error { + p.ASCIICodeBindings = append(p.ASCIICodeBindings, b...) + return nil + } +} + +// WithShowCompletionAtStart to set completion window is open at start. +func WithShowCompletionAtStart() Option { + return func(p *Prompt) error { + p.completion.showAtStart = true + return nil + } +} + +// WithBreakLineCallback to run a callback at every break line +func WithBreakLineCallback(fn func(*Document)) Option { + return func(p *Prompt) error { + p.renderer.breakLineCallback = fn + return nil + } +} + +// WithExitChecker set an exit function which checks if go-prompt exits its Run loop +func WithExitChecker(fn ExitChecker) Option { + return func(p *Prompt) error { + p.exitChecker = fn + return nil + } +} + +func DefaultExecuteOnEnterCallback(input string, indentSize int) (int, bool) { + return 0, true +} + +func DefaultPrefixCallback() string { + return "> " +} + +// 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, + 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, + }, + buf: NewBuffer(), + executor: executor, + history: NewHistory(), + completion: NewCompletionManager(6), + executeOnEnterCallback: DefaultExecuteOnEnterCallback, + indentSize: DefaultIndentSize, + keyBindMode: EmacsKeyBind, // All the above assume that bash is running in the default Emacs setting + } + + for _, opt := range opts { + if err := opt(pt); err != nil { + panic(err) + } + } + return pt +} diff --git a/internal/debug/assert.go b/debug/assert.go similarity index 100% rename from internal/debug/assert.go rename to debug/assert.go diff --git a/internal/debug/log.go b/debug/log.go similarity index 87% rename from internal/debug/log.go rename to debug/log.go index 51637862..8d20d444 100644 --- a/internal/debug/log.go +++ b/debug/log.go @@ -1,7 +1,7 @@ package debug import ( - "io/ioutil" + "io" "log" "os" ) @@ -25,11 +25,11 @@ func init() { return } } - logger = log.New(ioutil.Discard, "", log.Llongfile) + logger = log.New(io.Discard, "", log.Llongfile) } -// Teardown to close logfile -func Teardown() { +// Close to close logfile +func Close() { if logfile == nil { return } diff --git a/document.go b/document.go index b1996455..642c098c 100644 --- a/document.go +++ b/document.go @@ -2,11 +2,12 @@ package prompt import ( "strings" + "unicode" "unicode/utf8" - "github.com/elk-language/go-prompt/internal/bisect" - istrings "github.com/elk-language/go-prompt/internal/strings" - runewidth "github.com/mattn/go-runewidth" + "github.com/elk-language/go-prompt/bisect" + istrings "github.com/elk-language/go-prompt/strings" + "golang.org/x/exp/utf8string" ) // Document has text displayed in terminal and cursor position. @@ -15,7 +16,7 @@ type Document struct { // This represents a index in a rune array of Document.Text. // So if Document is "日本(cursor)語", cursorPosition is 2. // But DisplayedCursorPosition returns 4 because '日' and '本' are double width characters. - cursorPosition int + cursorPosition istrings.RuneNumber lastKey Key } @@ -34,24 +35,20 @@ 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() int { - var position int - runes := []rune(d.Text)[:d.cursorPosition] - for i := range runes { - position += runewidth.RuneWidth(runes[i]) - } - return position +func (d *Document) DisplayCursorPosition(columns istrings.StringWidth) Position { + str := utf8string.NewString(d.Text).Slice(0, int(d.cursorPosition)) + return positionAtEndOfString(str, columns) } // GetCharRelativeToCursor return character relative to cursor position, or empty string -func (d *Document) GetCharRelativeToCursor(offset int) (r rune) { +func (d *Document) GetCharRelativeToCursor(offset istrings.RuneNumber) (r rune) { s := d.Text - cnt := 0 + var cnt istrings.RuneNumber for len(s) > 0 { cnt++ r, size := utf8.DecodeRuneInString(s) - if cnt == d.cursorPosition+offset { + if cnt == d.cursorPosition+istrings.RuneNumber(offset) { return r } s = s[size:] @@ -127,25 +124,32 @@ func (d *Document) GetWordAfterCursorUntilSeparatorIgnoreNextToCursor(sep string // FindStartOfPreviousWord returns an index relative to the cursor position // pointing to the start of the previous word. Return 0 if nothing was found. -func (d *Document) FindStartOfPreviousWord() int { +func (d *Document) FindStartOfPreviousWord() istrings.ByteNumber { x := d.TextBeforeCursor() - i := strings.LastIndexByte(x, ' ') + i := istrings.ByteNumber(strings.LastIndexAny(x, " \n")) if i != -1 { return i + 1 } return 0 } +// Returns the rune count +// of the text before the cursor until the start of the previous word. +func (d *Document) FindRuneNumberUntilStartOfPreviousWord() istrings.RuneNumber { + x := d.TextBeforeCursor() + return istrings.RuneCount(x[d.FindStartOfPreviousWordWithSpace():]) +} + // FindStartOfPreviousWordWithSpace is almost the same as FindStartOfPreviousWord. // The only difference is to ignore contiguous spaces. -func (d *Document) FindStartOfPreviousWordWithSpace() int { +func (d *Document) FindStartOfPreviousWordWithSpace() istrings.ByteNumber { x := d.TextBeforeCursor() end := istrings.LastIndexNotByte(x, ' ') if end == -1 { return 0 } - start := strings.LastIndexByte(x[:end], ' ') + start := istrings.ByteNumber(strings.LastIndexByte(x[:end], ' ')) if start == -1 { return 0 } @@ -154,13 +158,13 @@ func (d *Document) FindStartOfPreviousWordWithSpace() int { // FindStartOfPreviousWordUntilSeparator is almost the same as FindStartOfPreviousWord. // But this can specify Separator. Return 0 if nothing was found. -func (d *Document) FindStartOfPreviousWordUntilSeparator(sep string) int { +func (d *Document) FindStartOfPreviousWordUntilSeparator(sep string) istrings.ByteNumber { if sep == "" { return d.FindStartOfPreviousWord() } x := d.TextBeforeCursor() - i := strings.LastIndexAny(x, sep) + i := istrings.ByteNumber(strings.LastIndexAny(x, sep)) if i != -1 { return i + 1 } @@ -169,7 +173,7 @@ func (d *Document) FindStartOfPreviousWordUntilSeparator(sep string) int { // FindStartOfPreviousWordUntilSeparatorIgnoreNextToCursor is almost the same as FindStartOfPreviousWordWithSpace. // But this can specify Separator. Return 0 if nothing was found. -func (d *Document) FindStartOfPreviousWordUntilSeparatorIgnoreNextToCursor(sep string) int { +func (d *Document) FindStartOfPreviousWordUntilSeparatorIgnoreNextToCursor(sep string) istrings.ByteNumber { if sep == "" { return d.FindStartOfPreviousWordWithSpace() } @@ -179,60 +183,83 @@ func (d *Document) FindStartOfPreviousWordUntilSeparatorIgnoreNextToCursor(sep s if end == -1 { return 0 } - start := strings.LastIndexAny(x[:end], sep) + start := istrings.ByteNumber(strings.LastIndexAny(x[:end], sep)) if start == -1 { return 0 } return start + 1 } -// FindEndOfCurrentWord returns an index relative to the cursor position. +// FindEndOfCurrentWord returns a byte index relative to the cursor position. // pointing to the end of the current word. Return 0 if nothing was found. -func (d *Document) FindEndOfCurrentWord() int { +func (d *Document) FindEndOfCurrentWord() istrings.ByteNumber { x := d.TextAfterCursor() - i := strings.IndexByte(x, ' ') + i := istrings.ByteNumber(strings.IndexByte(x, ' ')) if i != -1 { return i } - return len(x) + return istrings.ByteNumber(len(x)) } // FindEndOfCurrentWordWithSpace is almost the same as FindEndOfCurrentWord. // The only difference is to ignore contiguous spaces. -func (d *Document) FindEndOfCurrentWordWithSpace() int { +func (d *Document) FindEndOfCurrentWordWithSpace() istrings.ByteNumber { x := d.TextAfterCursor() start := istrings.IndexNotByte(x, ' ') if start == -1 { - return len(x) + return istrings.ByteNumber(len(x)) } - end := strings.IndexByte(x[start:], ' ') + end := istrings.ByteNumber(strings.IndexByte(x[start:], ' ')) if end == -1 { - return len(x) + return istrings.ByteNumber(len(x)) } return start + end } +// Returns the number of runes +// of the text after the cursor until the end of the current word. +func (d *Document) FindRuneNumberUntilEndOfCurrentWord() istrings.RuneNumber { + t := d.TextAfterCursor() + var count istrings.RuneNumber + nonSpaceCharSeen := false + for _, char := range t { + if !nonSpaceCharSeen && char == ' ' { + count += 1 + continue + } + + if nonSpaceCharSeen && char == ' ' { + break + } + + nonSpaceCharSeen = true + count += 1 + } + + return count +} + // FindEndOfCurrentWordUntilSeparator is almost the same as FindEndOfCurrentWord. // But this can specify Separator. Return 0 if nothing was found. -func (d *Document) FindEndOfCurrentWordUntilSeparator(sep string) int { +func (d *Document) FindEndOfCurrentWordUntilSeparator(sep string) istrings.ByteNumber { if sep == "" { return d.FindEndOfCurrentWord() } x := d.TextAfterCursor() - i := strings.IndexAny(x, sep) + i := istrings.ByteNumber(strings.IndexAny(x, sep)) if i != -1 { return i } - return len(x) + return istrings.ByteNumber(len(x)) } // FindEndOfCurrentWordUntilSeparatorIgnoreNextToCursor is almost the same as FindEndOfCurrentWordWithSpace. // But this can specify Separator. Return 0 if nothing was found. -func (d *Document) FindEndOfCurrentWordUntilSeparatorIgnoreNextToCursor(sep string) int { +func (d *Document) FindEndOfCurrentWordUntilSeparatorIgnoreNextToCursor(sep string) istrings.ByteNumber { if sep == "" { return d.FindEndOfCurrentWordWithSpace() } @@ -241,12 +268,12 @@ func (d *Document) FindEndOfCurrentWordUntilSeparatorIgnoreNextToCursor(sep stri start := istrings.IndexNotAny(x, sep) if start == -1 { - return len(x) + return istrings.ByteNumber(len(x)) } - end := strings.IndexAny(x[start:], sep) + end := istrings.ByteNumber(strings.IndexAny(x[start:], sep)) if end == -1 { - return len(x) + return istrings.ByteNumber(len(x)) } return start + end @@ -269,89 +296,111 @@ func (d *Document) CurrentLine() string { return d.CurrentLineBeforeCursor() + d.CurrentLineAfterCursor() } -// Array pointing to the start indexes of all the lines. -func (d *Document) lineStartIndexes() []int { +// Array pointing to the start indices of all the lines. +func (d *Document) lineStartIndices() []istrings.RuneNumber { // TODO: Cache, because this is often reused. // (If it is used, it's often used many times. // And this has to be fast for editing big documents!) lc := d.LineCount() - lengths := make([]int, lc) + lengths := make([]istrings.RuneNumber, lc) for i, l := range d.Lines() { - lengths[i] = len(l) + lengths[i] = istrings.RuneNumber(len([]rune(l))) } // Calculate cumulative sums. - indexes := make([]int, lc+1) - indexes[0] = 0 // https://github.com/jonathanslenders/python-prompt-toolkit/blob/master/prompt_toolkit/document.py#L189 - pos := 0 + indices := make([]istrings.RuneNumber, lc+1) + indices[0] = 0 // https://github.com/jonathanslenders/python-prompt-toolkit/blob/master/prompt_toolkit/document.py#L189 + var pos istrings.RuneNumber for i, l := range lengths { pos += l + 1 - indexes[i+1] = pos + indices[i+1] = istrings.RuneNumber(pos) } if lc > 1 { // Pop the last item. (This is not a new line.) - indexes = indexes[:lc] + indices = indices[:lc] } - return indexes + return indices } // For the index of a character at a certain line, calculate the index of // the first character on that line. -func (d *Document) findLineStartIndex(index int) (pos int, lineStartIndex int) { - indexes := d.lineStartIndexes() - pos = bisect.Right(indexes, index) - 1 - lineStartIndex = indexes[pos] +func (d *Document) findLineStartIndex(index istrings.RuneNumber) (pos, lineStartIndex istrings.RuneNumber) { + indices := d.lineStartIndices() + pos = bisect.Right(indices, index) - 1 + lineStartIndex = indices[pos] return } // CursorPositionRow returns the current row. (0-based.) -func (d *Document) CursorPositionRow() (row int) { +func (d *Document) CursorPositionRow() (row istrings.RuneNumber) { row, _ = d.findLineStartIndex(d.cursorPosition) return } // CursorPositionCol returns the current column. (0-based.) -func (d *Document) CursorPositionCol() (col int) { - // Don't use self.text_before_cursor to calculate this. Creating substrings - // and splitting is too expensive for getting the cursor position. +func (d *Document) CursorPositionCol() (col istrings.RuneNumber) { _, index := d.findLineStartIndex(d.cursorPosition) col = d.cursorPosition - index return } // GetCursorLeftPosition returns the relative position for cursor left. -func (d *Document) GetCursorLeftPosition(count int) int { +func (d *Document) GetCursorLeftPosition(count istrings.RuneNumber) istrings.RuneNumber { if count < 0 { return d.GetCursorRightPosition(-count) } - if d.CursorPositionCol() > count { - return -count + runeSlice := []rune(d.Text) + var counter istrings.RuneNumber + targetPosition := d.cursorPosition - count + if targetPosition < 0 { + targetPosition = 0 } - return -d.CursorPositionCol() + for range runeSlice[targetPosition:d.cursorPosition] { + counter-- + } + + return counter } // GetCursorRightPosition returns relative position for cursor right. -func (d *Document) GetCursorRightPosition(count int) int { +func (d *Document) GetCursorRightPosition(count istrings.RuneNumber) istrings.RuneNumber { if count < 0 { return d.GetCursorLeftPosition(-count) } - if len(d.CurrentLineAfterCursor()) > count { - return count + runeSlice := []rune(d.Text) + var counter istrings.RuneNumber + targetPosition := d.cursorPosition + count + if targetPosition > istrings.RuneNumber(len(runeSlice)) { + targetPosition = istrings.RuneNumber(len(runeSlice)) } - return len(d.CurrentLineAfterCursor()) + for range runeSlice[d.cursorPosition:targetPosition] { + counter++ + } + + return counter +} + +// Get the current cursor position. +func (d *Document) GetCursorPosition(columns istrings.StringWidth) Position { + return positionAtEndOfString(d.TextBeforeCursor(), columns) +} + +// Get the position of the end of the current text. +func (d *Document) GetEndOfTextPosition(columns istrings.StringWidth) Position { + return positionAtEndOfString(d.Text, columns) } // GetCursorUpPosition return the relative cursor position (character index) where we would be // if the user pressed the arrow-up button. -func (d *Document) GetCursorUpPosition(count int, preferredColumn int) int { - var col int +func (d *Document) GetCursorUpPosition(count int, preferredColumn istrings.RuneNumber) istrings.RuneNumber { + var col istrings.RuneNumber if preferredColumn == -1 { // -1 means nil col = d.CursorPositionCol() } else { col = preferredColumn } - row := d.CursorPositionRow() - count + row := int(d.CursorPositionRow()) - count if row < 0 { row = 0 } @@ -360,14 +409,14 @@ func (d *Document) GetCursorUpPosition(count int, preferredColumn int) int { // GetCursorDownPosition return the relative cursor position (character index) where we would be if the // user pressed the arrow-down button. -func (d *Document) GetCursorDownPosition(count int, preferredColumn int) int { - var col int +func (d *Document) GetCursorDownPosition(count int, preferredColumn istrings.RuneNumber) istrings.RuneNumber { + var col istrings.RuneNumber if preferredColumn == -1 { // -1 means nil col = d.CursorPositionCol() } else { col = preferredColumn } - row := d.CursorPositionRow() + count + row := int(d.CursorPositionRow()) + count return d.TranslateRowColToIndex(row, col) - d.cursorPosition } @@ -385,38 +434,39 @@ func (d *Document) LineCount() int { // TranslateIndexToPosition given an index for the text, return the corresponding (row, col) tuple. // (0-based. Returns (0, 0) for index=0.) -func (d *Document) TranslateIndexToPosition(index int) (row int, col int) { - row, rowIndex := d.findLineStartIndex(index) - col = index - rowIndex - return +func (d *Document) TranslateIndexToPosition(index istrings.RuneNumber) (int, int) { + r, rowIndex := d.findLineStartIndex(index) + c := index - rowIndex + return int(r), int(c) } // TranslateRowColToIndex given a (row, col), return the corresponding index. // (Row and col params are 0-based.) -func (d *Document) TranslateRowColToIndex(row int, column int) (index int) { - indexes := d.lineStartIndexes() +func (d *Document) TranslateRowColToIndex(row int, column istrings.RuneNumber) (index istrings.RuneNumber) { + indices := d.lineStartIndices() if row < 0 { row = 0 - } else if row > len(indexes) { - row = len(indexes) - 1 + } else if row > len(indices) { + row = len(indices) - 1 } - index = indexes[row] - line := d.Lines()[row] + index = indices[row] + line := []rune(d.Lines()[row]) // python) result += max(0, min(col, len(line))) if column > 0 || len(line) > 0 { - if column > len(line) { - index += len(line) + if column > istrings.RuneNumber(len(line)) { + index += istrings.RuneNumber(len(line)) } else { - index += column + index += istrings.RuneNumber(column) } } + text := []rune(d.Text) // Keep in range. (len(self.text) is included, because the cursor can be // right after the end of the text as well.) // python) result = max(0, min(result, len(self.text))) - if index > len(d.Text) { - index = len(d.Text) + if index > istrings.RuneNumber(len(text)) { + index = istrings.RuneNumber(len(text)) } if index < 0 { index = 0 @@ -426,12 +476,40 @@ func (d *Document) TranslateRowColToIndex(row int, column int) (index int) { // OnLastLine returns true when we are at the last line. func (d *Document) OnLastLine() bool { - return d.CursorPositionRow() == (d.LineCount() - 1) + return d.CursorPositionRow() == istrings.RuneNumber(d.LineCount()-1) } // GetEndOfLinePosition returns relative position for the end of this line. -func (d *Document) GetEndOfLinePosition() int { - return len([]rune(d.CurrentLineAfterCursor())) +func (d *Document) GetEndOfLinePosition() istrings.RuneNumber { + return istrings.RuneCount(d.CurrentLineAfterCursor()) +} + +// GetStartOfLinePosition returns relative position for the start of this line. +func (d *Document) GetStartOfLinePosition() istrings.RuneNumber { + return istrings.RuneCount(d.CurrentLineBeforeCursor()) +} + +// GetStartOfLinePosition returns relative position for the start of this line. +func (d *Document) FindStartOfFirstWordOfLine() istrings.RuneNumber { + line := d.CurrentLineBeforeCursor() + var counter istrings.RuneNumber + var nonSpaceCharSeen bool + for _, char := range line { + if !nonSpaceCharSeen && unicode.IsSpace(char) { + continue + } + + if !nonSpaceCharSeen { + nonSpaceCharSeen = true + } + counter++ + } + + if counter == 0 { + return istrings.RuneCount(line) + } + + return counter } func (d *Document) leadingWhitespaceInCurrentLine() (margin string) { diff --git a/document_test.go b/document_test.go index dd4d1022..56648d48 100644 --- a/document_test.go +++ b/document_test.go @@ -5,6 +5,8 @@ import ( "reflect" "testing" "unicode/utf8" + + istrings "github.com/elk-language/go-prompt/strings" ) func ExampleDocument_CurrentLine() { @@ -13,7 +15,7 @@ func ExampleDocument_CurrentLine() { This is a example of Document component. This component has texts displayed in terminal and cursor position. `, - cursorPosition: len(`Hello! my name is c-bata. + cursorPosition: istrings.RuneCount(`Hello! my name is c-bata. This is a exam`), } fmt.Println(d.CurrentLine()) @@ -24,11 +26,11 @@ This is a exam`), func ExampleDocument_DisplayCursorPosition() { d := &Document{ Text: `Hello! my name is c-bata.`, - cursorPosition: len(`Hello`), + cursorPosition: istrings.RuneCount(`Hello`), } - fmt.Println("DisplayCursorPosition", d.DisplayCursorPosition()) + fmt.Println("DisplayCursorPosition", d.DisplayCursorPosition(50)) // Output: - // DisplayCursorPosition 5 + // DisplayCursorPosition {5 0} } func ExampleDocument_CursorPositionRow() { @@ -37,7 +39,7 @@ func ExampleDocument_CursorPositionRow() { This is a example of Document component. This component has texts displayed in terminal and cursor position. `, - cursorPosition: len(`Hello! my name is c-bata. + cursorPosition: istrings.RuneCount(`Hello! my name is c-bata. This is a exam`), } fmt.Println("CursorPositionRow", d.CursorPositionRow()) @@ -51,7 +53,7 @@ func ExampleDocument_CursorPositionCol() { This is a example of Document component. This component has texts displayed in terminal and cursor position. `, - cursorPosition: len(`Hello! my name is c-bata. + cursorPosition: istrings.RuneCount(`Hello! my name is c-bata. This is a exam`), } fmt.Println("CursorPositionCol", d.CursorPositionCol()) @@ -65,7 +67,7 @@ func ExampleDocument_TextBeforeCursor() { This is a example of Document component. This component has texts displayed in terminal and cursor position. `, - cursorPosition: len(`Hello! my name is c-bata. + cursorPosition: istrings.RuneCount(`Hello! my name is c-bata. This is a exam`), } fmt.Println(d.TextBeforeCursor()) @@ -80,7 +82,7 @@ func ExampleDocument_TextAfterCursor() { This is a example of Document component. This component has texts displayed in terminal and cursor position. `, - cursorPosition: len(`Hello! my name is c-bata. + cursorPosition: istrings.RuneCount(`Hello! my name is c-bata. This is a exam`), } fmt.Println(d.TextAfterCursor()) @@ -94,9 +96,9 @@ func ExampleDocument_DisplayCursorPosition_withJapanese() { Text: `こんにちは、芝田 将です。`, cursorPosition: 3, } - fmt.Println("DisplayCursorPosition", d.DisplayCursorPosition()) + fmt.Println("DisplayCursorPosition", d.DisplayCursorPosition(30)) // Output: - // DisplayCursorPosition 6 + // DisplayCursorPosition {6 0} } func ExampleDocument_CurrentLineBeforeCursor() { @@ -105,7 +107,7 @@ func ExampleDocument_CurrentLineBeforeCursor() { This is a example of Document component. This component has texts displayed in terminal and cursor position. `, - cursorPosition: len(`Hello! my name is c-bata. + cursorPosition: istrings.RuneCount(`Hello! my name is c-bata. This is a exam`), } fmt.Println(d.CurrentLineBeforeCursor()) @@ -119,7 +121,7 @@ func ExampleDocument_CurrentLineAfterCursor() { This is a example of Document component. This component has texts displayed in terminal and cursor position. `, - cursorPosition: len(`Hello! my name is c-bata. + cursorPosition: istrings.RuneCount(`Hello! my name is c-bata. This is a exam`), } fmt.Println(d.CurrentLineAfterCursor()) @@ -132,7 +134,7 @@ func ExampleDocument_GetWordBeforeCursor() { Text: `Hello! my name is c-bata. This is a example of Document component. `, - cursorPosition: len(`Hello! my name is c-bata. + cursorPosition: istrings.RuneCount(`Hello! my name is c-bata. This is a exam`), } fmt.Println(d.GetWordBeforeCursor()) @@ -145,7 +147,7 @@ func ExampleDocument_GetWordAfterCursor() { Text: `Hello! my name is c-bata. This is a example of Document component. `, - cursorPosition: len(`Hello! my name is c-bata. + cursorPosition: istrings.RuneCount(`Hello! my name is c-bata. This is a exam`), } fmt.Println(d.GetWordAfterCursor()) @@ -158,7 +160,7 @@ func ExampleDocument_GetWordBeforeCursorWithSpace() { Text: `Hello! my name is c-bata. This is a example of Document component. `, - cursorPosition: len(`Hello! my name is c-bata. + cursorPosition: istrings.RuneCount(`Hello! my name is c-bata. This is a example `), } fmt.Println(d.GetWordBeforeCursorWithSpace()) @@ -171,7 +173,7 @@ func ExampleDocument_GetWordAfterCursorWithSpace() { Text: `Hello! my name is c-bata. This is a example of Document component. `, - cursorPosition: len(`Hello! my name is c-bata. + cursorPosition: istrings.RuneCount(`Hello! my name is c-bata. This is a`), } fmt.Println(d.GetWordAfterCursorWithSpace()) @@ -182,7 +184,7 @@ This is a`), func ExampleDocument_GetWordBeforeCursorUntilSeparator() { d := &Document{ Text: `hello,i am c-bata`, - cursorPosition: len(`hello,i am c`), + cursorPosition: istrings.RuneCount(`hello,i am c`), } fmt.Println(d.GetWordBeforeCursorUntilSeparator(",")) // Output: @@ -192,7 +194,7 @@ func ExampleDocument_GetWordBeforeCursorUntilSeparator() { func ExampleDocument_GetWordAfterCursorUntilSeparator() { d := &Document{ Text: `hello,i am c-bata,thank you for using go-prompt`, - cursorPosition: len(`hello,i a`), + cursorPosition: istrings.RuneCount(`hello,i a`), } fmt.Println(d.GetWordAfterCursorUntilSeparator(",")) // Output: @@ -202,7 +204,7 @@ func ExampleDocument_GetWordAfterCursorUntilSeparator() { func ExampleDocument_GetWordBeforeCursorUntilSeparatorIgnoreNextToCursor() { d := &Document{ Text: `hello,i am c-bata,thank you for using go-prompt`, - cursorPosition: len(`hello,i am c-bata,`), + cursorPosition: istrings.RuneCount(`hello,i am c-bata,`), } fmt.Println(d.GetWordBeforeCursorUntilSeparatorIgnoreNextToCursor(",")) // Output: @@ -212,7 +214,7 @@ func ExampleDocument_GetWordBeforeCursorUntilSeparatorIgnoreNextToCursor() { func ExampleDocument_GetWordAfterCursorUntilSeparatorIgnoreNextToCursor() { d := &Document{ Text: `hello,i am c-bata,thank you for using go-prompt`, - cursorPosition: len(`hello`), + cursorPosition: istrings.RuneCount(`hello`), } fmt.Println(d.GetWordAfterCursorUntilSeparatorIgnoreNextToCursor(",")) // Output: @@ -222,21 +224,21 @@ func ExampleDocument_GetWordAfterCursorUntilSeparatorIgnoreNextToCursor() { func TestDocument_DisplayCursorPosition(t *testing.T) { patterns := []struct { document *Document - expected int + expected Position }{ { document: &Document{ Text: "hello", cursorPosition: 2, }, - expected: 2, + expected: Position{X: 2}, }, { document: &Document{ Text: "こんにちは", cursorPosition: 2, }, - expected: 4, + expected: Position{X: 4}, }, { // If you're facing test failure on this test case and your terminal is iTerm2, @@ -247,12 +249,12 @@ func TestDocument_DisplayCursorPosition(t *testing.T) { Text: "Добрый день", cursorPosition: 3, }, - expected: 3, + expected: Position{X: 3}, }, } for _, p := range patterns { - ac := p.document.DisplayCursorPosition() + ac := p.document.DisplayCursorPosition(30) if ac != p.expected { t.Errorf("Should be %#v, got %#v", p.expected, ac) } @@ -267,7 +269,7 @@ func TestDocument_GetCharRelativeToCursor(t *testing.T) { { document: &Document{ Text: "line 1\nline 2\nline 3\nline 4\n", - cursorPosition: len([]rune("line 1\n" + "lin")), + cursorPosition: istrings.RuneCount("line 1\n" + "lin"), }, expected: "e", }, @@ -304,7 +306,7 @@ func TestDocument_TextBeforeCursor(t *testing.T) { { document: &Document{ Text: "line 1\nline 2\nline 3\nline 4\n", - cursorPosition: len("line 1\n" + "lin"), + cursorPosition: istrings.RuneCount("line 1\n" + "lin"), }, expected: "line 1\nlin", }, @@ -339,7 +341,7 @@ func TestDocument_TextAfterCursor(t *testing.T) { { document: &Document{ Text: "line 1\nline 2\nline 3\nline 4\n", - cursorPosition: len("line 1\n" + "lin"), + cursorPosition: istrings.RuneCount("line 1\n" + "lin"), }, expected: "e 2\nline 3\nline 4\n", }, @@ -383,14 +385,14 @@ func TestDocument_GetWordBeforeCursor(t *testing.T) { { document: &Document{ Text: "apple bana", - cursorPosition: len("apple bana"), + cursorPosition: istrings.RuneCount("apple bana"), }, expected: "bana", }, { document: &Document{ Text: "apply -f ./file/foo.json", - cursorPosition: len("apply -f ./file/foo.json"), + cursorPosition: istrings.RuneCount("apply -f ./file/foo.json"), }, expected: "foo.json", sep: " /", @@ -398,14 +400,14 @@ func TestDocument_GetWordBeforeCursor(t *testing.T) { { document: &Document{ Text: "apple banana orange", - cursorPosition: len("apple ba"), + cursorPosition: istrings.RuneCount("apple ba"), }, expected: "ba", }, { document: &Document{ Text: "apply -f ./file/foo.json", - cursorPosition: len("apply -f ./fi"), + cursorPosition: istrings.RuneCount("apply -f ./fi"), }, expected: "fi", sep: " /", @@ -413,7 +415,7 @@ func TestDocument_GetWordBeforeCursor(t *testing.T) { { document: &Document{ Text: "apple ", - cursorPosition: len("apple "), + cursorPosition: istrings.RuneCount("apple "), }, expected: "", }, @@ -461,14 +463,14 @@ func TestDocument_GetWordBeforeCursorWithSpace(t *testing.T) { { document: &Document{ Text: "apple bana ", - cursorPosition: len("apple bana "), + cursorPosition: istrings.RuneCount("apple bana "), }, expected: "bana ", }, { document: &Document{ Text: "apply -f /path/to/file/", - cursorPosition: len("apply -f /path/to/file/"), + cursorPosition: istrings.RuneCount("apply -f /path/to/file/"), }, expected: "file/", sep: " /", @@ -476,14 +478,14 @@ func TestDocument_GetWordBeforeCursorWithSpace(t *testing.T) { { document: &Document{ Text: "apple ", - cursorPosition: len("apple "), + cursorPosition: istrings.RuneCount("apple "), }, expected: "apple ", }, { document: &Document{ Text: "path/", - cursorPosition: len("path/"), + cursorPosition: istrings.RuneCount("path/"), }, expected: "path/", sep: " /", @@ -526,37 +528,37 @@ func TestDocument_GetWordBeforeCursorWithSpace(t *testing.T) { func TestDocument_FindStartOfPreviousWord(t *testing.T) { pattern := []struct { document *Document - expected int + expected istrings.ByteNumber sep string }{ { document: &Document{ Text: "apple bana", - cursorPosition: len("apple bana"), + cursorPosition: istrings.RuneCount("apple bana"), }, - expected: len("apple "), + expected: istrings.Len("apple "), }, { document: &Document{ Text: "apply -f ./file/foo.json", - cursorPosition: len("apply -f ./file/foo.json"), + cursorPosition: istrings.RuneCount("apply -f ./file/foo.json"), }, - expected: len("apply -f ./file/"), + expected: istrings.Len("apply -f ./file/"), sep: " /", }, { document: &Document{ Text: "apple ", - cursorPosition: len("apple "), + cursorPosition: istrings.RuneCount("apple "), }, - expected: len("apple "), + expected: istrings.Len("apple "), }, { document: &Document{ Text: "apply -f ./file/foo.json", - cursorPosition: len("apply -f ./"), + cursorPosition: istrings.RuneCount("apply -f ./"), }, - expected: len("apply -f ./"), + expected: istrings.Len("apply -f ./"), sep: " /", }, { @@ -564,14 +566,14 @@ func TestDocument_FindStartOfPreviousWord(t *testing.T) { Text: "あいうえお かきくけこ さしすせそ", cursorPosition: 8, // between 'き' and 'く' }, - expected: len("あいうえお "), // this function returns index byte in string + expected: istrings.Len("あいうえお "), // this function returns index byte in string }, { document: &Document{ Text: "Добрый день Добрый день", cursorPosition: 9, }, - expected: len("Добрый "), // this function returns index byte in string + expected: istrings.Len("Добрый "), // this function returns index byte in string }, } @@ -597,37 +599,37 @@ func TestDocument_FindStartOfPreviousWord(t *testing.T) { func TestDocument_FindStartOfPreviousWordWithSpace(t *testing.T) { pattern := []struct { document *Document - expected int + expected istrings.ByteNumber sep string }{ { document: &Document{ Text: "apple bana ", - cursorPosition: len("apple bana "), + cursorPosition: istrings.RuneCount("apple bana "), }, - expected: len("apple "), + expected: istrings.Len("apple "), }, { document: &Document{ Text: "apply -f /file/foo/", - cursorPosition: len("apply -f /file/foo/"), + cursorPosition: istrings.RuneCount("apply -f /file/foo/"), }, - expected: len("apply -f /file/"), + expected: istrings.Len("apply -f /file/"), sep: " /", }, { document: &Document{ Text: "apple ", - cursorPosition: len("apple "), + cursorPosition: istrings.RuneCount("apple "), }, - expected: len(""), + expected: istrings.Len(""), }, { document: &Document{ Text: "file/", - cursorPosition: len("file/"), + cursorPosition: istrings.RuneCount("file/"), }, - expected: len(""), + expected: istrings.Len(""), sep: " /", }, { @@ -635,14 +637,14 @@ func TestDocument_FindStartOfPreviousWordWithSpace(t *testing.T) { Text: "あいうえお かきくけこ ", cursorPosition: 12, // cursor points to last }, - expected: len("あいうえお "), // this function returns index byte in string + expected: istrings.Len("あいうえお "), // this function returns index byte in string }, { document: &Document{ Text: "Добрый день ", cursorPosition: 12, }, - expected: len("Добрый "), // this function returns index byte in string + expected: istrings.Len("Добрый "), // this function returns index byte in string }, } @@ -674,14 +676,14 @@ func TestDocument_GetWordAfterCursor(t *testing.T) { { document: &Document{ Text: "apple bana", - cursorPosition: len("apple bana"), + cursorPosition: istrings.RuneCount("apple bana"), }, expected: "", }, { document: &Document{ Text: "apply -f ./file/foo.json", - cursorPosition: len("apply -f ./fi"), + cursorPosition: istrings.RuneCount("apply -f ./fi"), }, expected: "le", sep: " /", @@ -689,21 +691,21 @@ func TestDocument_GetWordAfterCursor(t *testing.T) { { document: &Document{ Text: "apple bana", - cursorPosition: len("apple "), + cursorPosition: istrings.RuneCount("apple "), }, expected: "bana", }, { document: &Document{ Text: "apple bana", - cursorPosition: len("apple"), + cursorPosition: istrings.RuneCount("apple"), }, expected: "", }, { document: &Document{ Text: "apply -f ./file/foo.json", - cursorPosition: len("apply -f ."), + cursorPosition: istrings.RuneCount("apply -f ."), }, expected: "", sep: " /", @@ -711,7 +713,7 @@ func TestDocument_GetWordAfterCursor(t *testing.T) { { document: &Document{ Text: "apple bana", - cursorPosition: len("ap"), + cursorPosition: istrings.RuneCount("ap"), }, expected: "ple", }, @@ -759,21 +761,21 @@ func TestDocument_GetWordAfterCursorWithSpace(t *testing.T) { { document: &Document{ Text: "apple bana", - cursorPosition: len("apple bana"), + cursorPosition: istrings.RuneCount("apple bana"), }, expected: "", }, { document: &Document{ Text: "apple bana", - cursorPosition: len("apple "), + cursorPosition: istrings.RuneCount("apple "), }, expected: "bana", }, { document: &Document{ Text: "/path/to", - cursorPosition: len("/path/"), + cursorPosition: istrings.RuneCount("/path/"), }, expected: "to", sep: " /", @@ -781,7 +783,7 @@ func TestDocument_GetWordAfterCursorWithSpace(t *testing.T) { { document: &Document{ Text: "/path/to/file", - cursorPosition: len("/path/"), + cursorPosition: istrings.RuneCount("/path/"), }, expected: "to", sep: " /", @@ -789,14 +791,14 @@ func TestDocument_GetWordAfterCursorWithSpace(t *testing.T) { { document: &Document{ Text: "apple bana", - cursorPosition: len("apple"), + cursorPosition: istrings.RuneCount("apple"), }, expected: " bana", }, { document: &Document{ Text: "path/to", - cursorPosition: len("path"), + cursorPosition: istrings.RuneCount("path"), }, expected: "/to", sep: " /", @@ -804,7 +806,7 @@ func TestDocument_GetWordAfterCursorWithSpace(t *testing.T) { { document: &Document{ Text: "apple bana", - cursorPosition: len("ap"), + cursorPosition: istrings.RuneCount("ap"), }, expected: "ple", }, @@ -846,52 +848,52 @@ func TestDocument_GetWordAfterCursorWithSpace(t *testing.T) { func TestDocument_FindEndOfCurrentWord(t *testing.T) { pattern := []struct { document *Document - expected int + expected istrings.ByteNumber sep string }{ { document: &Document{ Text: "apple bana", - cursorPosition: len("apple bana"), + cursorPosition: istrings.RuneCount("apple bana"), }, - expected: len(""), + expected: istrings.Len(""), }, { document: &Document{ Text: "apple bana", - cursorPosition: len("apple "), + cursorPosition: istrings.RuneCount("apple "), }, - expected: len("bana"), + expected: istrings.Len("bana"), }, { document: &Document{ Text: "apply -f ./file/foo.json", - cursorPosition: len("apply -f ./"), + cursorPosition: istrings.RuneCount("apply -f ./"), }, - expected: len("file"), + expected: istrings.Len("file"), sep: " /", }, { document: &Document{ Text: "apple bana", - cursorPosition: len("apple"), + cursorPosition: istrings.RuneCount("apple"), }, - expected: len(""), + expected: istrings.Len(""), }, { document: &Document{ Text: "apply -f ./file/foo.json", - cursorPosition: len("apply -f ."), + cursorPosition: istrings.RuneCount("apply -f ."), }, - expected: len(""), + expected: istrings.Len(""), sep: " /", }, { document: &Document{ Text: "apple bana", - cursorPosition: len("ap"), + cursorPosition: istrings.RuneCount("ap"), }, - expected: len("ple"), + expected: istrings.Len("ple"), }, { // りん(cursor)ご ばなな @@ -899,7 +901,7 @@ func TestDocument_FindEndOfCurrentWord(t *testing.T) { Text: "りんご ばなな", cursorPosition: 2, }, - expected: len("ご"), + expected: istrings.Len("ご"), }, { document: &Document{ @@ -914,7 +916,7 @@ func TestDocument_FindEndOfCurrentWord(t *testing.T) { Text: "Добрый день", cursorPosition: 3, }, - expected: len("рый"), + expected: istrings.Len("рый"), }, } @@ -940,73 +942,73 @@ func TestDocument_FindEndOfCurrentWord(t *testing.T) { func TestDocument_FindEndOfCurrentWordWithSpace(t *testing.T) { pattern := []struct { document *Document - expected int + expected istrings.ByteNumber sep string }{ { document: &Document{ Text: "apple bana", - cursorPosition: len("apple bana"), + cursorPosition: istrings.RuneCount("apple bana"), }, - expected: len(""), + expected: istrings.Len(""), }, { document: &Document{ Text: "apple bana", - cursorPosition: len("apple "), + cursorPosition: istrings.RuneCount("apple "), }, - expected: len("bana"), + expected: istrings.Len("bana"), }, { document: &Document{ Text: "apply -f /file/foo.json", - cursorPosition: len("apply -f /"), + cursorPosition: istrings.RuneCount("apply -f /"), }, - expected: len("file"), + expected: istrings.Len("file"), sep: " /", }, { document: &Document{ Text: "apple bana", - cursorPosition: len("apple"), + cursorPosition: istrings.RuneCount("apple"), }, - expected: len(" bana"), + expected: istrings.Len(" bana"), }, { document: &Document{ Text: "apply -f /path/to", - cursorPosition: len("apply -f /path"), + cursorPosition: istrings.RuneCount("apply -f /path"), }, - expected: len("/to"), + expected: istrings.Len("/to"), sep: " /", }, { document: &Document{ Text: "apple bana", - cursorPosition: len("ap"), + cursorPosition: istrings.RuneCount("ap"), }, - expected: len("ple"), + expected: istrings.Len("ple"), }, { document: &Document{ Text: "あいうえお かきくけこ", cursorPosition: 6, }, - expected: len("かきくけこ"), + expected: istrings.Len("かきくけこ"), }, { document: &Document{ Text: "あいうえお かきくけこ", cursorPosition: 5, }, - expected: len(" かきくけこ"), + expected: istrings.Len(" かきくけこ"), }, { document: &Document{ Text: "Добрый день", cursorPosition: 6, }, - expected: len(" день"), + expected: istrings.Len(" день"), }, } @@ -1032,7 +1034,7 @@ func TestDocument_FindEndOfCurrentWordWithSpace(t *testing.T) { func TestDocument_CurrentLineBeforeCursor(t *testing.T) { d := &Document{ Text: "line 1\nline 2\nline 3\nline 4\n", - cursorPosition: len("line 1\n" + "lin"), + cursorPosition: istrings.RuneCount("line 1\n" + "lin"), } ac := d.CurrentLineBeforeCursor() ex := "lin" @@ -1044,7 +1046,7 @@ func TestDocument_CurrentLineBeforeCursor(t *testing.T) { func TestDocument_CurrentLineAfterCursor(t *testing.T) { d := &Document{ Text: "line 1\nline 2\nline 3\nline 4\n", - cursorPosition: len("line 1\n" + "lin"), + cursorPosition: istrings.RuneCount("line 1\n" + "lin"), } ac := d.CurrentLineAfterCursor() ex := "e 2" @@ -1056,7 +1058,7 @@ func TestDocument_CurrentLineAfterCursor(t *testing.T) { func TestDocument_CurrentLine(t *testing.T) { d := &Document{ Text: "line 1\nline 2\nline 3\nline 4\n", - cursorPosition: len("line 1\n" + "lin"), + cursorPosition: istrings.RuneCount("line 1\n" + "lin"), } ac := d.CurrentLine() ex := "line 2" @@ -1068,11 +1070,11 @@ func TestDocument_CurrentLine(t *testing.T) { func TestDocument_CursorPositionRowAndCol(t *testing.T) { var cursorPositionTests = []struct { document *Document - expectedRow int - expectedCol int + expectedRow istrings.RuneNumber + expectedCol istrings.RuneNumber }{ { - document: &Document{Text: "line 1\nline 2\nline 3\n", cursorPosition: len("line 1\n" + "lin")}, + document: &Document{Text: "line 1\nline 2\nline 3\n", cursorPosition: istrings.RuneCount("line 1\n" + "lin")}, expectedRow: 1, expectedCol: 3, }, @@ -1097,15 +1099,21 @@ func TestDocument_CursorPositionRowAndCol(t *testing.T) { func TestDocument_GetCursorLeftPosition(t *testing.T) { d := &Document{ Text: "line 1\nline 2\nline 3\nline 4\n", - cursorPosition: len("line 1\n" + "line 2\n" + "lin"), + cursorPosition: istrings.RuneCount("line 1\n" + "line 2\n" + "lin"), } ac := d.GetCursorLeftPosition(2) - ex := -2 + var ex istrings.RuneNumber = -2 if ac != ex { t.Errorf("Should be %#v, got %#v", ex, ac) } ac = d.GetCursorLeftPosition(10) - ex = -3 + ex = -10 + if ac != ex { + t.Errorf("Should be %#v, got %#v", ex, ac) + } + + ac = d.GetCursorLeftPosition(30) + ex = -17 if ac != ex { t.Errorf("Should be %#v, got %#v", ex, ac) } @@ -1114,16 +1122,16 @@ func TestDocument_GetCursorLeftPosition(t *testing.T) { func TestDocument_GetCursorUpPosition(t *testing.T) { d := &Document{ Text: "line 1\nline 2\nline 3\nline 4\n", - cursorPosition: len("line 1\n" + "line 2\n" + "lin"), + cursorPosition: istrings.RuneCount("line 1\n" + "line 2\n" + "lin"), } ac := d.GetCursorUpPosition(2, -1) - ex := len("lin") - len("line 1\n"+"line 2\n"+"lin") + ex := istrings.RuneCount("lin") - istrings.RuneCount("line 1\n"+"line 2\n"+"lin") if ac != ex { t.Errorf("Should be %#v, got %#v", ex, ac) } ac = d.GetCursorUpPosition(100, -1) - ex = len("lin") - len("line 1\n"+"line 2\n"+"lin") + ex = istrings.RuneCount("lin") - istrings.RuneCount("line 1\n"+"line 2\n"+"lin") if ac != ex { t.Errorf("Should be %#v, got %#v", ex, ac) } @@ -1132,16 +1140,16 @@ func TestDocument_GetCursorUpPosition(t *testing.T) { func TestDocument_GetCursorDownPosition(t *testing.T) { d := &Document{ Text: "line 1\nline 2\nline 3\nline 4\n", - cursorPosition: len("lin"), + cursorPosition: istrings.RuneCount("lin"), } ac := d.GetCursorDownPosition(2, -1) - ex := len("line 1\n"+"line 2\n"+"lin") - len("lin") + ex := istrings.RuneCount("line 1\n"+"line 2\n"+"lin") - istrings.RuneCount("lin") if ac != ex { t.Errorf("Should be %#v, got %#v", ex, ac) } ac = d.GetCursorDownPosition(100, -1) - ex = len("line 1\n"+"line 2\n"+"line 3\n"+"line 4\n") - len("lin") + ex = istrings.RuneCount("line 1\n"+"line 2\n"+"line 3\n"+"line 4\n") - istrings.RuneCount("lin") if ac != ex { t.Errorf("Should be %#v, got %#v", ex, ac) } @@ -1150,15 +1158,21 @@ func TestDocument_GetCursorDownPosition(t *testing.T) { func TestDocument_GetCursorRightPosition(t *testing.T) { d := &Document{ Text: "line 1\nline 2\nline 3\nline 4\n", - cursorPosition: len("line 1\n" + "line 2\n" + "lin"), + cursorPosition: istrings.RuneCount("line 1\n" + "line 2\n" + "lin"), } ac := d.GetCursorRightPosition(2) - ex := 2 + var ex istrings.RuneNumber = 2 if ac != ex { t.Errorf("Should be %#v, got %#v", ex, ac) } ac = d.GetCursorRightPosition(10) - ex = 3 + ex = 10 + if ac != ex { + t.Errorf("Should be %#v, got %#v", ex, ac) + } + + ac = d.GetCursorRightPosition(30) + ex = 11 if ac != ex { t.Errorf("Should be %#v, got %#v", ex, ac) } @@ -1167,7 +1181,7 @@ func TestDocument_GetCursorRightPosition(t *testing.T) { func TestDocument_Lines(t *testing.T) { d := &Document{ Text: "line 1\nline 2\nline 3\nline 4\n", - cursorPosition: len("line 1\n" + "lin"), + cursorPosition: istrings.RuneCount("line 1\n" + "lin"), } ac := d.Lines() ex := []string{"line 1", "line 2", "line 3", "line 4", ""} @@ -1179,7 +1193,7 @@ func TestDocument_Lines(t *testing.T) { func TestDocument_LineCount(t *testing.T) { d := &Document{ Text: "line 1\nline 2\nline 3\nline 4\n", - cursorPosition: len("line 1\n" + "lin"), + cursorPosition: istrings.RuneCount("line 1\n" + "lin"), } ac := d.LineCount() ex := 5 @@ -1191,9 +1205,9 @@ func TestDocument_LineCount(t *testing.T) { func TestDocument_TranslateIndexToPosition(t *testing.T) { d := &Document{ Text: "line 1\nline 2\nline 3\nline 4\n", - cursorPosition: len("line 1\n" + "lin"), + cursorPosition: istrings.RuneCount("line 1\n" + "lin"), } - row, col := d.TranslateIndexToPosition(len("line 1\nline 2\nlin")) + row, col := d.TranslateIndexToPosition(istrings.RuneCount("line 1\nline 2\nlin")) if row != 2 { t.Errorf("Should be %#v, got %#v", 2, row) } @@ -1212,10 +1226,10 @@ func TestDocument_TranslateIndexToPosition(t *testing.T) { func TestDocument_TranslateRowColToIndex(t *testing.T) { d := &Document{ Text: "line 1\nline 2\nline 3\nline 4\n", - cursorPosition: len("line 1\n" + "lin"), + cursorPosition: istrings.RuneCount("line 1\n" + "lin"), } ac := d.TranslateRowColToIndex(2, 3) - ex := len("line 1\nline 2\nlin") + ex := istrings.RuneCount("line 1\nline 2\nlin") if ac != ex { t.Errorf("Should be %#v, got %#v", ex, ac) } @@ -1229,13 +1243,13 @@ func TestDocument_TranslateRowColToIndex(t *testing.T) { func TestDocument_OnLastLine(t *testing.T) { d := &Document{ Text: "line 1\nline 2\nline 3", - cursorPosition: len("line 1\nline"), + cursorPosition: istrings.RuneCount("line 1\nline"), } ac := d.OnLastLine() if ac { t.Errorf("Should be %#v, got %#v", false, ac) } - d.cursorPosition = len("line 1\nline 2\nline") + d.cursorPosition = istrings.RuneCount("line 1\nline 2\nline") ac = d.OnLastLine() if !ac { t.Errorf("Should be %#v, got %#v", true, ac) @@ -1245,10 +1259,10 @@ func TestDocument_OnLastLine(t *testing.T) { func TestDocument_GetEndOfLinePosition(t *testing.T) { d := &Document{ Text: "line 1\nline 2\nline 3", - cursorPosition: len("line 1\nli"), + cursorPosition: istrings.RuneCount("line 1\nli"), } ac := d.GetEndOfLinePosition() - ex := len("ne 2") + ex := istrings.RuneCount("ne 2") if ac != ex { t.Errorf("Should be %#v, got %#v", ex, ac) } diff --git a/emacs.go b/emacs.go index 636606bd..4770a360 100644 --- a/emacs.go +++ b/emacs.go @@ -1,6 +1,9 @@ package prompt -import "github.com/elk-language/go-prompt/internal/debug" +import ( + "github.com/elk-language/go-prompt/debug" + istrings "github.com/elk-language/go-prompt/strings" +) /* @@ -43,32 +46,28 @@ var emacsKeyBindings = []KeyBind{ { Key: ControlE, Fn: func(buf *Buffer) { - x := []rune(buf.Document().TextAfterCursor()) - buf.CursorRight(len(x)) + buf.CursorRight(istrings.RuneCount(buf.Document().CurrentLineAfterCursor())) }, }, // Go to the beginning of the line { Key: ControlA, Fn: func(buf *Buffer) { - x := []rune(buf.Document().TextBeforeCursor()) - buf.CursorLeft(len(x)) + buf.CursorLeft(buf.Document().FindStartOfFirstWordOfLine()) }, }, // Cut the Line after the cursor { Key: ControlK, Fn: func(buf *Buffer) { - x := []rune(buf.Document().TextAfterCursor()) - buf.Delete(len(x)) + buf.Delete(istrings.RuneCount(buf.Document().CurrentLineAfterCursor())) }, }, // Cut/delete the Line before the cursor { Key: ControlU, Fn: func(buf *Buffer) { - x := []rune(buf.Document().TextBeforeCursor()) - buf.DeleteBeforeCursor(len(x)) + buf.DeleteBeforeCursor(istrings.RuneCount(buf.Document().CurrentLineBeforeCursor())) }, }, // Delete character under the cursor @@ -98,7 +97,7 @@ var emacsKeyBindings = []KeyBind{ { Key: AltRight, Fn: func(buf *Buffer) { - buf.CursorRight(buf.Document().FindEndOfCurrentWordWithSpace()) + buf.CursorRight(buf.Document().FindRuneNumberUntilEndOfCurrentWord()) }, }, // Left allow: Backward one character @@ -112,20 +111,20 @@ var emacsKeyBindings = []KeyBind{ { Key: AltLeft, Fn: func(buf *Buffer) { - buf.CursorLeft(len([]rune(buf.Document().TextBeforeCursor())) - buf.Document().FindStartOfPreviousWordWithSpace()) + buf.CursorLeft(buf.Document().FindRuneNumberUntilStartOfPreviousWord()) }, }, // Cut the Word before the cursor. { Key: ControlW, Fn: func(buf *Buffer) { - buf.DeleteBeforeCursor(len([]rune(buf.Document().GetWordBeforeCursorWithSpace()))) + buf.DeleteBeforeCursor(istrings.RuneCount(buf.Document().GetWordBeforeCursorWithSpace())) }, }, { Key: AltBackspace, Fn: func(buf *Buffer) { - buf.DeleteBeforeCursor(len([]rune(buf.Document().GetWordBeforeCursorWithSpace()))) + buf.DeleteBeforeCursor(istrings.RuneCount(buf.Document().GetWordBeforeCursorWithSpace())) }, }, // Clear the Screen, similar to the clear command diff --git a/emacs_test.go b/emacs_test.go index bfda59e6..605c3bd8 100644 --- a/emacs_test.go +++ b/emacs_test.go @@ -1,11 +1,15 @@ package prompt -import "testing" +import ( + "testing" + + istrings "github.com/elk-language/go-prompt/strings" +) func TestEmacsKeyBindings(t *testing.T) { buf := NewBuffer() buf.InsertText("abcde", false, true) - if buf.cursorPosition != len("abcde") { + if buf.cursorPosition != istrings.RuneNumber(len("abcde")) { t.Errorf("Want %d, but got %d", len("abcde"), buf.cursorPosition) } @@ -17,7 +21,7 @@ func TestEmacsKeyBindings(t *testing.T) { // Go to the end of the line applyEmacsKeyBind(buf, ControlE) - if buf.cursorPosition != len("abcde") { + if buf.cursorPosition != istrings.RuneNumber(len("abcde")) { t.Errorf("Want %d, but got %d", len("abcde"), buf.cursorPosition) } } diff --git a/go.mod b/go.mod index d559da46..0db5cac3 100644 --- a/go.mod +++ b/go.mod @@ -1,12 +1,15 @@ module github.com/elk-language/go-prompt -go 1.14 +go 1.19 require ( - github.com/google/go-cmp v0.5.9 // indirect + github.com/google/go-cmp v0.5.9 github.com/mattn/go-colorable v0.1.7 github.com/mattn/go-runewidth v0.0.9 github.com/mattn/go-tty v0.0.3 github.com/pkg/term v1.2.0-beta.2 - golang.org/x/sys v0.0.0-20200918174421-af09f7315aff + golang.org/x/exp v0.0.0-20230626212559-97b1e661b5df + golang.org/x/sys v0.1.0 ) + +require github.com/mattn/go-isatty v0.0.12 // indirect diff --git a/go.sum b/go.sum index a0af5319..8b177a46 100644 --- a/go.sum +++ b/go.sum @@ -14,6 +14,8 @@ github.com/mattn/go-tty v0.0.3 h1:5OfyWorkyO7xP52Mq7tB36ajHDG5OHrmBGIS/DtakQI= github.com/mattn/go-tty v0.0.3/go.mod h1:ihxohKRERHTVzN+aSVRwACLCeqIoZAWpoICkkvrWyR0= github.com/pkg/term v1.2.0-beta.2 h1:L3y/h2jkuBVFdWiJvNfYfKmzcCnILw7mJWm2JQuMppw= github.com/pkg/term v1.2.0-beta.2/go.mod h1:E25nymQcrSllhX42Ok8MRm1+hyBdHY0dCeiKZ9jpNGw= +golang.org/x/exp v0.0.0-20230626212559-97b1e661b5df h1:UA2aFVmmsIlefxMk29Dp2juaUSth8Pyn3Tq5Y5mJGME= +golang.org/x/exp v0.0.0-20230626212559-97b1e661b5df/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20191008105621-543471e840be/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -21,5 +23,5 @@ golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200909081042-eff7692f9009/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200918174421-af09f7315aff h1:1CPUrky56AcgSpxz/KfgzQWzfG09u5YOL8MvPYBlrL8= -golang.org/x/sys v0.0.0-20200918174421-af09f7315aff/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.1.0 h1:kunALQeHf1/185U1i0GOB/fy1IPRDDpuoOOqRReG57U= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/key_bind.go b/key_bind.go index 42669e7f..0332e27d 100644 --- a/key_bind.go +++ b/key_bind.go @@ -16,13 +16,13 @@ type ASCIICodeBind struct { } // KeyBindMode to switch a key binding flexibly. -type KeyBindMode string +type KeyBindMode uint8 const ( // CommonKeyBind is a mode without any keyboard shortcut - CommonKeyBind KeyBindMode = "common" + CommonKeyBind KeyBindMode = iota // EmacsKeyBind is a mode to use emacs-like keyboard shortcut - EmacsKeyBind KeyBindMode = "emacs" + EmacsKeyBind ) var commonKeyBindings = []KeyBind{ diff --git a/key_bind_func.go b/key_bind_func.go index 7b2ecdf6..7b49f713 100644 --- a/key_bind_func.go +++ b/key_bind_func.go @@ -1,15 +1,19 @@ package prompt +import ( + istrings "github.com/elk-language/go-prompt/strings" +) + // GoLineEnd Go to the End of the line func GoLineEnd(buf *Buffer) { x := []rune(buf.Document().TextAfterCursor()) - buf.CursorRight(len(x)) + buf.CursorRight(istrings.RuneNumber(len(x))) } // GoLineBeginning Go to the beginning of the line func GoLineBeginning(buf *Buffer) { x := []rune(buf.Document().TextBeforeCursor()) - buf.CursorLeft(len(x)) + buf.CursorLeft(istrings.RuneNumber(len(x))) } // DeleteChar Delete character under the cursor @@ -17,11 +21,6 @@ func DeleteChar(buf *Buffer) { buf.Delete(1) } -// DeleteWord Delete word before the cursor -func DeleteWord(buf *Buffer) { - buf.DeleteBeforeCursor(len([]rune(buf.Document().TextBeforeCursor())) - buf.Document().FindStartOfPreviousWordWithSpace()) -} - // DeleteBeforeChar Go to Backspace func DeleteBeforeChar(buf *Buffer) { buf.DeleteBeforeCursor(1) @@ -36,13 +35,3 @@ func GoRightChar(buf *Buffer) { func GoLeftChar(buf *Buffer) { buf.CursorLeft(1) } - -// GoRightWord Forward one word -func GoRightWord(buf *Buffer) { - buf.CursorRight(buf.Document().FindEndOfCurrentWordWithSpace()) -} - -// GoLeftWord Backward one word -func GoLeftWord(buf *Buffer) { - buf.CursorLeft(len([]rune(buf.Document().TextBeforeCursor())) - buf.Document().FindStartOfPreviousWordWithSpace()) -} diff --git a/option.go b/option.go deleted file mode 100644 index 8aaca334..00000000 --- a/option.go +++ /dev/null @@ -1,318 +0,0 @@ -package prompt - -// Option is the type to replace default parameters. -// prompt.New accepts any number of options (this is functional option pattern). -type Option func(prompt *Prompt) error - -// OptionParser to set a custom ConsoleParser object. An argument should implement ConsoleParser interface. -func OptionParser(x ConsoleParser) Option { - return func(p *Prompt) error { - p.in = x - return nil - } -} - -// OptionWriter to set a custom ConsoleWriter object. An argument should implement ConsoleWriter interface. -func OptionWriter(x ConsoleWriter) Option { - return func(p *Prompt) error { - registerConsoleWriter(x) - p.renderer.out = x - return nil - } -} - -// OptionTitle to set title displayed at the header bar of terminal. -func OptionTitle(x string) Option { - return func(p *Prompt) error { - p.renderer.title = x - return nil - } -} - -// OptionPrefix to set prefix string. -func OptionPrefix(x string) Option { - return func(p *Prompt) error { - p.renderer.prefix = x - return nil - } -} - -// OptionInitialBufferText to set the initial buffer text -func OptionInitialBufferText(x string) Option { - return func(p *Prompt) error { - p.buf.InsertText(x, false, true) - return nil - } -} - -// OptionCompletionWordSeparator to set word separators. Enable only ' ' if empty. -func OptionCompletionWordSeparator(x string) Option { - return func(p *Prompt) error { - p.completion.wordSeparator = x - return nil - } -} - -// OptionLivePrefix to change the prefix dynamically by callback function -func OptionLivePrefix(f func() (prefix string, useLivePrefix bool)) Option { - return func(p *Prompt) error { - p.renderer.livePrefixCallback = f - return nil - } -} - -// OptionPrefixTextColor change a text color of prefix string -func OptionPrefixTextColor(x Color) Option { - return func(p *Prompt) error { - p.renderer.prefixTextColor = x - return nil - } -} - -// OptionPrefixBackgroundColor to change a background color of prefix string -func OptionPrefixBackgroundColor(x Color) Option { - return func(p *Prompt) error { - p.renderer.prefixBGColor = x - return nil - } -} - -// OptionInputTextColor to change a color of text which is input by user -func OptionInputTextColor(x Color) Option { - return func(p *Prompt) error { - p.renderer.inputTextColor = x - return nil - } -} - -// OptionInputBGColor to change a color of background which is input by user -func OptionInputBGColor(x Color) Option { - return func(p *Prompt) error { - p.renderer.inputBGColor = x - return nil - } -} - -// OptionPreviewSuggestionTextColor to change a text color which is completed -func OptionPreviewSuggestionTextColor(x Color) Option { - return func(p *Prompt) error { - p.renderer.previewSuggestionTextColor = x - return nil - } -} - -// OptionPreviewSuggestionBGColor to change a background color which is completed -func OptionPreviewSuggestionBGColor(x Color) Option { - return func(p *Prompt) error { - p.renderer.previewSuggestionBGColor = x - return nil - } -} - -// OptionSuggestionTextColor to change a text color in drop down suggestions. -func OptionSuggestionTextColor(x Color) Option { - return func(p *Prompt) error { - p.renderer.suggestionTextColor = x - return nil - } -} - -// OptionSuggestionBGColor change a background color in drop down suggestions. -func OptionSuggestionBGColor(x Color) Option { - return func(p *Prompt) error { - p.renderer.suggestionBGColor = x - return nil - } -} - -// OptionSelectedSuggestionTextColor to change a text color for completed text which is selected inside suggestions drop down box. -func OptionSelectedSuggestionTextColor(x Color) Option { - return func(p *Prompt) error { - p.renderer.selectedSuggestionTextColor = x - return nil - } -} - -// OptionSelectedSuggestionBGColor to change a background color for completed text which is selected inside suggestions drop down box. -func OptionSelectedSuggestionBGColor(x Color) Option { - return func(p *Prompt) error { - p.renderer.selectedSuggestionBGColor = x - return nil - } -} - -// OptionDescriptionTextColor to change a background color of description text in drop down suggestions. -func OptionDescriptionTextColor(x Color) Option { - return func(p *Prompt) error { - p.renderer.descriptionTextColor = x - return nil - } -} - -// OptionDescriptionBGColor to change a background color of description text in drop down suggestions. -func OptionDescriptionBGColor(x Color) Option { - return func(p *Prompt) error { - p.renderer.descriptionBGColor = x - return nil - } -} - -// OptionSelectedDescriptionTextColor to change a text color of description which is selected inside suggestions drop down box. -func OptionSelectedDescriptionTextColor(x Color) Option { - return func(p *Prompt) error { - p.renderer.selectedDescriptionTextColor = x - return nil - } -} - -// OptionSelectedDescriptionBGColor to change a background color of description which is selected inside suggestions drop down box. -func OptionSelectedDescriptionBGColor(x Color) Option { - return func(p *Prompt) error { - p.renderer.selectedDescriptionBGColor = x - return nil - } -} - -// OptionScrollbarThumbColor to change a thumb color on scrollbar. -func OptionScrollbarThumbColor(x Color) Option { - return func(p *Prompt) error { - p.renderer.scrollbarThumbColor = x - return nil - } -} - -// OptionScrollbarBGColor to change a background color of scrollbar. -func OptionScrollbarBGColor(x Color) Option { - return func(p *Prompt) error { - p.renderer.scrollbarBGColor = x - return nil - } -} - -// OptionMaxSuggestion specify the max number of displayed suggestions. -func OptionMaxSuggestion(x uint16) Option { - return func(p *Prompt) error { - p.completion.max = x - return nil - } -} - -// OptionHistory to set history expressed by string array. -func OptionHistory(x []string) Option { - return func(p *Prompt) error { - p.history.histories = x - p.history.Clear() - return nil - } -} - -// OptionSwitchKeyBindMode set a key bind mode. -func OptionSwitchKeyBindMode(m KeyBindMode) Option { - return func(p *Prompt) error { - p.keyBindMode = m - return nil - } -} - -// OptionCompletionOnDown allows for Down arrow key to trigger completion. -func OptionCompletionOnDown() Option { - return func(p *Prompt) error { - p.completionOnDown = true - return nil - } -} - -// SwitchKeyBindMode to set a key bind mode. -// Deprecated: Please use OptionSwitchKeyBindMode. -var SwitchKeyBindMode = OptionSwitchKeyBindMode - -// OptionAddKeyBind to set a custom key bind. -func OptionAddKeyBind(b ...KeyBind) Option { - return func(p *Prompt) error { - p.keyBindings = append(p.keyBindings, b...) - return nil - } -} - -// OptionAddASCIICodeBind to set a custom key bind. -func OptionAddASCIICodeBind(b ...ASCIICodeBind) Option { - return func(p *Prompt) error { - p.ASCIICodeBindings = append(p.ASCIICodeBindings, b...) - return nil - } -} - -// OptionShowCompletionAtStart to set completion window is open at start. -func OptionShowCompletionAtStart() Option { - return func(p *Prompt) error { - p.completion.showAtStart = true - return nil - } -} - -// OptionBreakLineCallback to run a callback at every break line -func OptionBreakLineCallback(fn func(*Document)) Option { - return func(p *Prompt) error { - p.renderer.breakLineCallback = fn - return nil - } -} - -// OptionSetExitCheckerOnInput set an exit function which checks if go-prompt exits its Run loop -func OptionSetExitCheckerOnInput(fn ExitChecker) Option { - return func(p *Prompt) error { - p.exitChecker = fn - return nil - } -} - -// OptionSetLexer set lexer function and enable it. -func OptionSetLexer(lex Lexer) Option { - return func(p *Prompt) error { - p.lexer = lex - return nil - } -} - -// New returns a Prompt with powerful auto-completion. -func New(executor Executor, completer Completer, opts ...Option) *Prompt { - defaultWriter := NewStdoutWriter() - registerConsoleWriter(defaultWriter) - - pt := &Prompt{ - in: NewStandardInputParser(), - renderer: &Render{ - prefix: "> ", - out: defaultWriter, - livePrefixCallback: func() (string, bool) { return "", false }, - 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, - }, - buf: NewBuffer(), - executor: executor, - history: NewHistory(), - completion: NewCompletionManager(completer, 6), - keyBindMode: EmacsKeyBind, // All the above assume that bash is running in the default Emacs setting - } - - for _, opt := range opts { - if err := opt(pt); err != nil { - panic(err) - } - } - return pt -} diff --git a/position.go b/position.go new file mode 100644 index 00000000..decccd40 --- /dev/null +++ b/position.go @@ -0,0 +1,95 @@ +package prompt + +import ( + "io" + "strings" + + istrings "github.com/elk-language/go-prompt/strings" + "github.com/mattn/go-runewidth" +) + +// Position stores the coordinates +// of a p. +// +// (0, 0) represents the top-left corner of the prompt, +// while (n, n) the bottom-right corner. +type Position struct { + X istrings.StringWidth + Y int +} + +// Join two positions and return a new position. +func (p Position) Join(other Position) Position { + if other.Y == 0 { + p.X += other.X + } else { + p.X = other.X + p.Y += other.Y + } + return p +} + +// Add two positions and return a new position. +func (p Position) Add(other Position) Position { + return Position{ + X: p.X + other.X, + Y: p.Y + other.Y, + } +} + +// Subtract two positions and return a new position. +func (p Position) Subtract(other Position) Position { + return Position{ + X: p.X - other.X, + Y: p.Y - other.Y, + } +} + +// positionAtEndOfString calculates the position of the +// p at the end of the given string. +func positionAtEndOfString(str string, columns istrings.StringWidth) 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 { + var down int + var right istrings.StringWidth + +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' { + down++ + right = 0 + } + case '\n': + down++ + right = 0 + default: + right += istrings.StringWidth(runewidth.RuneWidth(char)) + if right == columns { + right = 0 + down++ + } + } + } + + return Position{ + X: right, + Y: down, + } +} diff --git a/position_test.go b/position_test.go new file mode 100644 index 00000000..49984d0b --- /dev/null +++ b/position_test.go @@ -0,0 +1,190 @@ +//go:build !windows +// +build !windows + +package prompt + +import ( + "testing" + + istrings "github.com/elk-language/go-prompt/strings" + "github.com/google/go-cmp/cmp" +) + +func TestPositionAtEndOfString(t *testing.T) { + tests := map[string]struct { + input string + columns istrings.StringWidth + want Position + }{ + "empty": { + input: "", + columns: 20, + want: Position{ + X: 0, + Y: 0, + }, + }, + "one letter": { + input: "f", + columns: 20, + want: Position{ + X: 1, + Y: 0, + }, + }, + "one-line fits in columns": { + input: "foo bar", + columns: 20, + want: Position{ + X: 7, + Y: 0, + }, + }, + "multiline": { + input: "foo\nbar\n", + columns: 20, + want: Position{ + X: 0, + Y: 2, + }, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + got := positionAtEndOfString(tc.input, tc.columns) + if diff := cmp.Diff(tc.want, got); diff != "" { + t.Fatalf(diff) + } + }) + } +} + +func TestPositionAdd(t *testing.T) { + tests := map[string]struct { + left Position + right Position + want Position + }{ + "empty": { + left: Position{}, + right: Position{}, + want: Position{}, + }, + "only X": { + left: Position{X: 1}, + right: Position{X: 2}, + want: Position{X: 3}, + }, + "only Y": { + left: Position{Y: 1}, + right: Position{Y: 2}, + want: Position{Y: 3}, + }, + "different coordinates": { + left: Position{X: 1}, + right: Position{Y: 2}, + want: Position{X: 1, Y: 2}, + }, + "both X and Y": { + left: Position{X: 1, Y: 5}, + right: Position{X: 10, Y: 2}, + want: Position{X: 11, Y: 7}, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + got := tc.left.Add(tc.right) + if diff := cmp.Diff(tc.want, got); diff != "" { + t.Fatalf(diff) + } + }) + } +} + +func TestPositionSubtract(t *testing.T) { + tests := map[string]struct { + left Position + right Position + want Position + }{ + "empty": { + left: Position{}, + right: Position{}, + want: Position{}, + }, + "only X": { + left: Position{X: 1}, + right: Position{X: 2}, + want: Position{X: -1}, + }, + "only Y": { + left: Position{Y: 5}, + right: Position{Y: 2}, + want: Position{Y: 3}, + }, + "different coordinates": { + left: Position{X: 1}, + right: Position{Y: 2}, + want: Position{X: 1, Y: -2}, + }, + "both X and Y": { + left: Position{X: 1, Y: 5}, + right: Position{X: 10, Y: 2}, + want: Position{X: -9, Y: 3}, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + got := tc.left.Subtract(tc.right) + if diff := cmp.Diff(tc.want, got); diff != "" { + t.Fatalf(diff) + } + }) + } +} + +func TestPositionJoin(t *testing.T) { + tests := map[string]struct { + left Position + right Position + want Position + }{ + "empty": { + left: Position{}, + right: Position{}, + want: Position{}, + }, + "only X": { + left: Position{X: 1}, + right: Position{X: 2}, + want: Position{X: 3}, + }, + "only Y": { + left: Position{Y: 1}, + right: Position{Y: 2}, + want: Position{Y: 3}, + }, + "different coordinates": { + left: Position{X: 5}, + right: Position{Y: 2}, + want: Position{X: 0, Y: 2}, + }, + "both X and Y": { + left: Position{X: 1, Y: 5}, + right: Position{X: 10, Y: 2}, + want: Position{X: 10, Y: 7}, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + got := tc.left.Join(tc.right) + if diff := cmp.Diff(tc.want, got); diff != "" { + t.Fatalf(diff) + } + }) + } +} diff --git a/prompt.go b/prompt.go index 4fc13cea..217b8fb4 100644 --- a/prompt.go +++ b/prompt.go @@ -3,13 +3,17 @@ package prompt import ( "bytes" "os" + "strings" "time" "unicode" "unicode/utf8" - "github.com/elk-language/go-prompt/internal/debug" + "github.com/elk-language/go-prompt/debug" + istrings "github.com/elk-language/go-prompt/strings" ) +const inputBufferSize = 1024 + // Executor is called when the user // inputs a line of text. type Executor func(string) @@ -21,39 +25,49 @@ type Executor func(string) // Exit means exit go-prompt (not the overall Go program) type ExitChecker func(in string, breakline bool) bool +// ExecuteOnEnterCallback is a function that receives +// user input after Enter has been pressed +// and determines whether the input should be executed. +// If this function returns true, the Executor callback will be called +// otherwise a newline will be added to the buffer containing user input +// and optionally indentation made up of `indentSize * indent` spaces. +type ExecuteOnEnterCallback func(input string, indentSize int) (indent int, execute bool) + // Completer is a function that returns // a slice of suggestions for the given Document. type Completer func(Document) []Suggest // Prompt is a core struct of go-prompt. type Prompt struct { - in ConsoleParser - buf *Buffer - renderer *Render - executor Executor - history *History - lexer Lexer - completion *CompletionManager - keyBindings []KeyBind - ASCIICodeBindings []ASCIICodeBind - keyBindMode KeyBindMode - completionOnDown bool - exitChecker ExitChecker - skipTearDown bool + reader Reader + buf *Buffer + renderer *Render + executor Executor + history *History + lexer Lexer + completion *CompletionManager + keyBindings []KeyBind + ASCIICodeBindings []ASCIICodeBind + keyBindMode KeyBindMode + completionOnDown bool + indentSize int // How many spaces constitute a single indentation level + exitChecker ExitChecker + executeOnEnterCallback ExecuteOnEnterCallback + skipClose bool } -// Exec is the struct that contains the user input context. -type Exec struct { +// UserInput is the struct that contains the user input context. +type UserInput struct { input string } // Run starts the prompt. func (p *Prompt) Run() { - p.skipTearDown = false - defer debug.Teardown() + p.skipClose = false + defer debug.Close() debug.Log("start prompt") - p.setUp() - defer p.tearDown() + p.setup() + defer p.Close() if p.completion.showAtStart { p.completion.Update(*p.buf.Document()) @@ -85,7 +99,7 @@ func (p *Prompt) Run() { // Unset raw mode // Reset to Blocking mode because returned EAGAIN when still set non-blocking mode. - debug.AssertNoError(p.in.TearDown()) + debug.AssertNoError(p.reader.Close()) p.executor(e.input) p.completion.Update(*p.buf.Document()) @@ -93,11 +107,11 @@ func (p *Prompt) Run() { p.renderer.Render(p.buf, p.completion, p.lexer) if p.exitChecker != nil && p.exitChecker(e.input, true) { - p.skipTearDown = true + p.skipClose = true return } // Set raw mode - debug.AssertNoError(p.in.Setup()) + debug.AssertNoError(p.reader.Open()) go p.readBuffer(bufCh, stopReadBufCh) go p.handleSignals(exitCh, winSizeCh, stopHandleSignalCh) } else { @@ -109,7 +123,7 @@ func (p *Prompt) Run() { p.renderer.Render(p.buf, p.completion, p.lexer) case code := <-exitCh: p.renderer.BreakLine(p.buf, p.lexer) - p.tearDown() + p.Close() os.Exit(code) default: time.Sleep(10 * time.Millisecond) @@ -117,39 +131,111 @@ func (p *Prompt) Run() { } } -func (p *Prompt) feed(b []byte) (shouldExit bool, exec *Exec) { +// 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) p.buf.lastKeyStroke = key // completion completing := p.completion.Completing() p.handleCompletionKeyBinding(key, completing) +keySwitch: switch key { case Enter, ControlJ, ControlM: - p.renderer.BreakLine(p.buf, p.lexer) + indent, execute := p.executeOnEnterCallback(p.buf.Text(), p.indentSize) + if !execute { + p.buf.NewLine(false) + var indentStrBuilder strings.Builder + indentUnitCount := indent * p.indentSize + for i := 0; i < indentUnitCount; i++ { + indentStrBuilder.WriteRune(IndentUnit) + } + p.buf.InsertText(indentStrBuilder.String(), false, true) + break + } - exec = &Exec{input: p.buf.Text()} + p.renderer.BreakLine(p.buf, p.lexer) + userInput = &UserInput{input: p.buf.Text()} p.buf = NewBuffer() - if exec.input != "" { - p.history.Add(exec.input) + if userInput.input != "" { + p.history.Add(userInput.input) + } + case Tab: + if len(p.completion.GetSuggestions()) > 0 { + // If there are any suggestions, select the next one + p.completion.Next() + break + } + + // if there are no suggestions insert indentation + newBytes := make([]byte, 0, len(b)) + for _, byt := range b { + switch byt { + case '\t': + for i := 0; i < p.indentSize; i++ { + newBytes = append(newBytes, IndentUnit) + } + default: + newBytes = append(newBytes, byt) + } + } + p.buf.InsertText(string(newBytes), false, true) + case BackTab: + if len(p.completion.GetSuggestions()) > 0 { + // If there are any suggestions, select the previous one + p.completion.Previous() + break + } + + text := p.buf.Document().CurrentLineBeforeCursor() + for _, char := range text { + if char != IndentUnit { + break keySwitch + } } + p.buf.DeleteBeforeCursor(istrings.RuneNumber(p.indentSize)) case ControlC: p.renderer.BreakLine(p.buf, p.lexer) p.buf = NewBuffer() p.history.Clear() case Up, ControlP: - if !completing { // Don't use p.completion.Completing() because it takes double operation when switch to selected=-1. - if newBuf, changed := p.history.Older(p.buf); changed { - p.buf = newBuf - } + cursor := p.buf.Document().GetCursorPosition(p.renderer.col) + if cursor.Y != 0 { + p.buf.CursorUp(1) + break + } + if completing { + break + } + + if newBuf, changed := p.history.Older(p.buf); changed { + p.buf = newBuf } + case Down, ControlN: - if !completing { // Don't use p.completion.Completing() because it takes double operation when switch to selected=-1. - if newBuf, changed := p.history.Newer(p.buf); changed { - p.buf = newBuf - } - return + endOfTextCursor := p.buf.Document().GetEndOfTextPosition(p.renderer.col) + cursor := p.buf.Document().GetCursorPosition(p.renderer.col) + if endOfTextCursor.Y > cursor.Y { + p.buf.CursorDown(1) + break + } + + if completing { + break } + + if newBuf, changed := p.history.Newer(p.buf); changed { + p.buf = newBuf + } + return case ControlD: if p.buf.Text() == "" { shouldExit = true @@ -177,19 +263,17 @@ func (p *Prompt) handleCompletionKeyBinding(key Key, completing bool) { if completing || p.completionOnDown { p.completion.Next() } - case Tab, ControlI: + case ControlI: p.completion.Next() case Up: if completing { p.completion.Previous() } - case BackTab: - p.completion.Previous() default: if s, ok := p.completion.GetSelectedSuggestion(); ok { w := p.buf.Document().GetWordBeforeCursorUntilSeparator(p.completion.wordSeparator) if w != "" { - p.buf.DeleteBeforeCursor(len([]rune(w))) + p.buf.DeleteBeforeCursor(istrings.RuneNumber(len([]rune(w)))) } p.buf.InsertText(s.Text, false, true) } @@ -206,7 +290,8 @@ func (p *Prompt) handleKeyBinding(key Key) bool { } } - if p.keyBindMode == EmacsKeyBind { + switch p.keyBindMode { + case EmacsKeyBind: for i := range emacsKeyBindings { kb := emacsKeyBindings[i] if kb.Key == key { @@ -242,10 +327,10 @@ func (p *Prompt) handleASCIICodeBinding(b []byte) bool { // Input starts the prompt, lets the user // input a single line and returns this line as a string. func (p *Prompt) Input() string { - defer debug.Teardown() + defer debug.Close() debug.Log("start prompt") - p.setUp() - defer p.tearDown() + p.setup() + defer p.Close() if p.completion.showAtStart { p.completion.Update(*p.buf.Document()) @@ -277,6 +362,9 @@ func (p *Prompt) Input() string { } } +const IndentUnit = ' ' +const IndentUnitString = string(IndentUnit) + func (p *Prompt) readBuffer(bufCh chan []byte, stopCh chan struct{}) { debug.Log("start reading buffer") for { @@ -285,18 +373,33 @@ func (p *Prompt) readBuffer(bufCh chan []byte, stopCh chan struct{}) { debug.Log("stop reading buffer") return default: - if bytes, err := p.in.Read(); err == nil && !(len(bytes) == 1 && bytes[0] == 0) { - // bufCh <- bytes - newBytes := make([]byte, len(bytes)) - for i, byt := range bytes { + bytes := make([]byte, inputBufferSize) + n, err := p.reader.Read(bytes) + if err != nil { + break + } + bytes = bytes[:n] + if len(bytes) == 1 && bytes[0] == '\t' { + // if only a single Tab key has been pressed + // handle it as a keybind + bufCh <- bytes + } else if len(bytes) != 1 || bytes[0] != 0 { + newBytes := make([]byte, 0, len(bytes)) + for _, byt := range bytes { + switch byt { // translate raw mode \r into \n // to make pasting multiline text // work properly - switch byt { case '\r': - newBytes[i] = '\n' + newBytes = append(newBytes, '\n') + // translate \t into two spaces + // to avoid problems with cursor positions + case '\t': + for i := 0; i < p.indentSize; i++ { + newBytes = append(newBytes, IndentUnit) + } default: - newBytes[i] = byt + newBytes = append(newBytes, byt) } } bufCh <- newBytes @@ -306,15 +409,15 @@ func (p *Prompt) readBuffer(bufCh chan []byte, stopCh chan struct{}) { } } -func (p *Prompt) setUp() { - debug.AssertNoError(p.in.Setup()) +func (p *Prompt) setup() { + debug.AssertNoError(p.reader.Open()) p.renderer.Setup() - p.renderer.UpdateWinSize(p.in.GetWinSize()) + p.renderer.UpdateWinSize(p.reader.GetWinSize()) } -func (p *Prompt) tearDown() { - if !p.skipTearDown { - debug.AssertNoError(p.in.TearDown()) +func (p *Prompt) Close() { + if !p.skipClose { + debug.AssertNoError(p.reader.Close()) } - p.renderer.TearDown() + p.renderer.Close() } diff --git a/input.go b/reader.go similarity index 96% rename from input.go rename to reader.go index 3c60ce58..a4a8b5f2 100644 --- a/input.go +++ b/reader.go @@ -1,6 +1,9 @@ package prompt -import "bytes" +import ( + "bytes" + "io" +) // WinSize represents the width and height of terminal. type WinSize struct { @@ -8,16 +11,13 @@ type WinSize struct { Col uint16 } -// ConsoleParser is an interface to abstract input layer. -type ConsoleParser interface { - // Setup should be called before starting input - Setup() error - // TearDown should be called after stopping input - TearDown() error +// Reader is an interface to abstract input layer. +type Reader interface { + // Open should be called before starting reading + Open() error // GetWinSize returns WinSize object to represent width and height of terminal. GetWinSize() *WinSize - // Read returns byte array. - Read() ([]byte, error) + io.ReadCloser } // GetKey returns Key correspond to input byte codes. diff --git a/input_posix.go b/reader_posix.go similarity index 57% rename from input_posix.go rename to reader_posix.go index afaa3dc0..a22d3590 100644 --- a/input_posix.go +++ b/reader_posix.go @@ -7,19 +7,17 @@ import ( "os" "syscall" - "github.com/elk-language/go-prompt/internal/term" + "github.com/elk-language/go-prompt/term" "golang.org/x/sys/unix" ) -const maxReadBytes = 1024 - -// PosixParser is a ConsoleParser implementation for POSIX environment. -type PosixParser struct { +// PosixReader is a Reader implementation for the POSIX environment. +type PosixReader struct { fd int } -// Setup should be called before starting input -func (t *PosixParser) Setup() error { +// Open should be called before starting input +func (t *PosixReader) Open() error { in, err := syscall.Open("/dev/tty", syscall.O_RDONLY, 0) if os.IsNotExist(err) { in = syscall.Stdin @@ -37,8 +35,8 @@ func (t *PosixParser) Setup() error { return nil } -// TearDown should be called after stopping input -func (t *PosixParser) TearDown() error { +// Close should be called after stopping input +func (t *PosixReader) Close() error { if err := syscall.Close(t.fd); err != nil { return err } @@ -49,17 +47,12 @@ func (t *PosixParser) TearDown() error { } // Read returns byte array. -func (t *PosixParser) Read() ([]byte, error) { - buf := make([]byte, maxReadBytes) - n, err := syscall.Read(t.fd, buf) - if err != nil { - return []byte{}, err - } - return buf[:n], nil +func (t *PosixReader) Read(buff []byte) (int, error) { + return syscall.Read(t.fd, buff) } // GetWinSize returns WinSize object to represent width and height of terminal. -func (t *PosixParser) GetWinSize() *WinSize { +func (t *PosixReader) GetWinSize() *WinSize { ws, err := unix.IoctlGetWinsize(t.fd, unix.TIOCGWINSZ) if err != nil { // If this errors, we simply return the default window size as @@ -75,9 +68,9 @@ func (t *PosixParser) GetWinSize() *WinSize { } } -var _ ConsoleParser = &PosixParser{} +var _ Reader = &PosixReader{} -// NewStandardInputParser returns ConsoleParser object to read from stdin. -func NewStandardInputParser() *PosixParser { - return &PosixParser{} +// NewStdinReader returns Reader object to read from stdin. +func NewStdinReader() *PosixReader { + return &PosixReader{} } diff --git a/input_test.go b/reader_test.go similarity index 100% rename from input_test.go rename to reader_test.go diff --git a/input_windows.go b/reader_windows.go similarity index 53% rename from input_windows.go rename to reader_windows.go index a0c3dc44..f6f64d87 100644 --- a/input_windows.go +++ b/reader_windows.go @@ -12,19 +12,17 @@ import ( tty "github.com/mattn/go-tty" ) -const maxReadBytes = 1024 - var kernel32 = syscall.NewLazyDLL("kernel32.dll") var procGetNumberOfConsoleInputEvents = kernel32.NewProc("GetNumberOfConsoleInputEvents") -// WindowsParser is a ConsoleParser implementation for Win32 console. -type WindowsParser struct { +// WindowsReader is a Reader implementation for Win32 console. +type WindowsReader struct { tty *tty.TTY } -// Setup should be called before starting input -func (p *WindowsParser) Setup() error { +// Open should be called before starting input +func (p *WindowsReader) Open() error { t, err := tty.Open() if err != nil { return err @@ -33,41 +31,40 @@ func (p *WindowsParser) Setup() error { return nil } -// TearDown should be called after stopping input -func (p *WindowsParser) TearDown() error { +// Close should be called after stopping input +func (p *WindowsReader) Close() error { return p.tty.Close() } // Read returns byte array. -func (p *WindowsParser) Read() ([]byte, error) { +func (p *WindowsReader) Read(buff []byte) (int, error) { var ev uint32 r0, _, err := procGetNumberOfConsoleInputEvents.Call(p.tty.Input().Fd(), uintptr(unsafe.Pointer(&ev))) if r0 == 0 { - return nil, err + return 0, err } if ev == 0 { - return nil, errors.New("EAGAIN") + return 0, errors.New("EAGAIN") } r, err := p.tty.ReadRune() if err != nil { - return nil, err + return 0, err } - buf := make([]byte, maxReadBytes) - n := utf8.EncodeRune(buf[:], r) - for p.tty.Buffered() && n < maxReadBytes { + n := utf8.EncodeRune(buff[:], r) + for p.tty.Buffered() && n < len(buff) { r, err := p.tty.ReadRune() if err != nil { break } - n += utf8.EncodeRune(buf[n:], r) + n += utf8.EncodeRune(buff[n:], r) } - return buf[:n], nil + return n, nil } // GetWinSize returns WinSize object to represent width and height of terminal. -func (p *WindowsParser) GetWinSize() *WinSize { +func (p *WindowsReader) GetWinSize() *WinSize { w, h, err := p.tty.Size() if err != nil { panic(err) @@ -78,7 +75,9 @@ func (p *WindowsParser) GetWinSize() *WinSize { } } -// NewStandardInputParser returns ConsoleParser object to read from stdin. -func NewStandardInputParser() *WindowsParser { - return &WindowsParser{} +var _ Reader = &WindowsReader{} + +// NewStdinReader returns Reader object to read from stdin. +func NewStdinReader() *WindowsReader { + return &WindowsReader{} } diff --git a/render.go b/render.go index e865d137..7cc69e05 100644 --- a/render.go +++ b/render.go @@ -4,21 +4,21 @@ import ( "runtime" "strings" - "github.com/elk-language/go-prompt/internal/debug" + "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. type Render struct { - out ConsoleWriter - prefix string - livePrefixCallback func() (prefix string, useLivePrefix bool) - breakLineCallback func(*Document) - title string - row uint16 - col uint16 + out Writer + prefixCallback PrefixCallback + breakLineCallback func(*Document) + title string + row uint16 + col istrings.StringWidth - previousCursor int + previousCursor Position // colors, prefixTextColor Color @@ -50,21 +50,22 @@ func (r *Render) Setup() { // getCurrentPrefix to get current prefix. // If live-prefix is enabled, return live-prefix. func (r *Render) getCurrentPrefix() string { - if prefix, ok := r.livePrefixCallback(); ok { - return prefix - } - return r.prefix + return r.prefixCallback() } func (r *Render) renderPrefix() { r.out.SetColor(r.prefixTextColor, r.prefixBGColor, false) - r.out.WriteStr("\r") - r.out.WriteStr(r.getCurrentPrefix()) + if _, err := r.out.WriteString("\r"); err != nil { + panic(err) + } + if _, err := r.out.WriteString(r.getCurrentPrefix()); err != nil { + panic(err) + } r.out.SetColor(DefaultColor, DefaultColor, false) } -// TearDown to clear title and erasing. -func (r *Render) TearDown() { +// Close to clear title and erasing. +func (r *Render) Close() { r.out.ClearTitle() r.out.EraseDown() debug.AssertNoError(r.out.Flush()) @@ -82,19 +83,21 @@ 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 = ws.Col + r.col = istrings.StringWidth(ws.Col) } func (r *Render) renderWindowTooSmall() { r.out.CursorGoTo(0, 0) r.out.EraseScreen() r.out.SetColor(DarkRed, White, false) - r.out.WriteStr("Your console window is too small...") + if _, err := r.out.WriteString("Your console window is too small..."); err != nil { + panic(err) + } } func (r *Render) renderCompletion(buf *Buffer, completions *CompletionManager) { suggestions := completions.GetSuggestions() - if len(completions.GetSuggestions()) == 0 { + if len(suggestions) == 0 { return } prefix := r.getCurrentPrefix() @@ -112,10 +115,10 @@ func (r *Render) renderCompletion(buf *Buffer, completions *CompletionManager) { formatted = formatted[completions.verticalScroll : completions.verticalScroll+windowHeight] r.prepareArea(windowHeight) - cursor := runewidth.StringWidth(prefix) + runewidth.StringWidth(buf.Document().TextBeforeCursor()) - x, _ := r.toPos(cursor) - if x+width >= int(r.col) { - cursor = r.backward(cursor, x+width-int(r.col)) + cursor := positionAtEndOfString(prefix+buf.Document().TextBeforeCursor(), r.col) + x := cursor.X + if x+width >= r.col { + cursor = r.backward(cursor, x+width-r.col) } contentHeight := len(completions.tmp) @@ -135,36 +138,43 @@ func (r *Render) renderCompletion(buf *Buffer, completions *CompletionManager) { r.out.SetColor(White, Cyan, false) for i := 0; i < windowHeight; i++ { - alignNextLine(r, cursorColumnSpacing) + alignNextLine(r, cursorColumnSpacing.X) if i == selected { r.out.SetColor(r.selectedSuggestionTextColor, r.selectedSuggestionBGColor, true) } else { r.out.SetColor(r.suggestionTextColor, r.suggestionBGColor, false) } - r.out.WriteStr(formatted[i].Text) + if _, err := r.out.WriteString(formatted[i].Text); err != nil { + panic(err) + } if i == selected { r.out.SetColor(r.selectedDescriptionTextColor, r.selectedDescriptionBGColor, false) } else { r.out.SetColor(r.descriptionTextColor, r.descriptionBGColor, false) } - r.out.WriteStr(formatted[i].Description) + if _, err := r.out.WriteString(formatted[i].Description); err != nil { + panic(err) + } if isScrollThumb(i) { r.out.SetColor(DefaultColor, r.scrollbarThumbColor, false) } else { r.out.SetColor(DefaultColor, r.scrollbarBGColor, false) } - r.out.WriteStr(" ") + if _, err := r.out.WriteString(" "); err != nil { + panic(err) + } r.out.SetColor(DefaultColor, DefaultColor, false) - r.lineWrap(cursor + width) - r.backward(cursor+width, width) + c := cursor.Add(Position{X: width}) + r.lineWrap(&c) + r.backward(c, width) } - if x+width >= int(r.col) { - r.out.CursorForward(x + width - int(r.col)) + if x+width >= r.col { + r.out.CursorForward(int(x + width - r.col)) } r.out.CursorUp(windowHeight) @@ -179,14 +189,15 @@ func (r *Render) Render(buffer *Buffer, completion *CompletionManager, lexer Lex return } defer func() { debug.AssertNoError(r.out.Flush()) }() - r.move(r.previousCursor, 0) + r.clear(r.previousCursor) line := buffer.Text() prefix := r.getCurrentPrefix() - cursor := runewidth.StringWidth(prefix) + runewidth.StringWidth(line) + prefixWidth := istrings.StringWidth(runewidth.StringWidth(prefix)) + cursor := positionAtEndOfString(prefix+line, r.col) // prepare area - _, y := r.toPos(cursor) + y := cursor.Y h := y + 1 + int(completion.max) if h > int(r.row) || completionMargin > int(r.col) { @@ -204,40 +215,50 @@ func (r *Render) Render(buffer *Buffer, completion *CompletionManager, lexer Lex r.lex(lexer, line) } else { r.out.SetColor(r.inputTextColor, r.inputBGColor, false) - r.out.WriteStr(line) + if _, err := r.out.WriteString(line); err != nil { + panic(err) + } } r.out.SetColor(DefaultColor, DefaultColor, false) - r.lineWrap(cursor) - - r.out.EraseDown() + r.lineWrap(&cursor) - cursor = r.backward(cursor, runewidth.StringWidth(line)-buffer.DisplayCursorPosition()) + targetCursor := buffer.DisplayCursorPosition(r.col) + if targetCursor.Y == 0 { + targetCursor.X += prefixWidth + } + cursor = r.move(cursor, targetCursor) r.renderCompletion(buffer, completion) if suggest, ok := completion.GetSelectedSuggestion(); ok { - cursor = r.backward(cursor, runewidth.StringWidth(buffer.Document().GetWordBeforeCursorUntilSeparator(completion.wordSeparator))) + cursor = r.backward(cursor, istrings.StringWidth(runewidth.StringWidth(buffer.Document().GetWordBeforeCursorUntilSeparator(completion.wordSeparator)))) r.out.SetColor(r.previewSuggestionTextColor, r.previewSuggestionBGColor, false) - r.out.WriteStr(suggest.Text) + if _, err := r.out.WriteString(suggest.Text); err != nil { + panic(err) + } r.out.SetColor(DefaultColor, DefaultColor, false) - cursor += runewidth.StringWidth(suggest.Text) + cursor.X += istrings.StringWidth(runewidth.StringWidth(suggest.Text)) + endOfSuggestionPos := cursor rest := buffer.Document().TextAfterCursor() if lexer != nil { r.lex(lexer, rest) } else { - r.out.WriteStr(rest) + if _, err := r.out.WriteString(rest); err != nil { + panic(err) + } } r.out.SetColor(DefaultColor, DefaultColor, false) - cursor += runewidth.StringWidth(rest) - r.lineWrap(cursor) + cursor = cursor.Join(positionAtEndOfString(rest, r.col)) + + r.lineWrap(&cursor) - cursor = r.backward(cursor, runewidth.StringWidth(rest)) + cursor = r.move(cursor, endOfSuggestionPos) } r.previousCursor = cursor } @@ -258,14 +279,16 @@ func (r *Render) lex(lexer Lexer, input string) { s = strings.TrimPrefix(s, a[0]) r.out.SetColor(token.Color(), r.inputBGColor, false) - r.out.WriteStr(a[0]) + if _, err := r.out.WriteString(a[0]); err != nil { + panic(err) + } } } // BreakLine to break line. func (r *Render) BreakLine(buffer *Buffer, lexer Lexer) { // Erasing and Render - cursor := runewidth.StringWidth(buffer.Document().TextBeforeCursor()) + runewidth.StringWidth(r.getCurrentPrefix()) + cursor := positionAtEndOfString(buffer.Document().TextBeforeCursor()+r.getCurrentPrefix(), r.col) r.clear(cursor) r.renderPrefix() @@ -274,7 +297,9 @@ func (r *Render) BreakLine(buffer *Buffer, lexer Lexer) { r.lex(lexer, buffer.Document().Text+"\n") } else { r.out.SetColor(r.inputTextColor, r.inputBGColor, false) - r.out.WriteStr(buffer.Document().Text + "\n") + if _, err := r.out.WriteString(buffer.Document().Text + "\n"); err != nil { + panic(err) + } } r.out.SetColor(DefaultColor, DefaultColor, false) @@ -284,41 +309,35 @@ func (r *Render) BreakLine(buffer *Buffer, lexer Lexer) { r.breakLineCallback(buffer.Document()) } - r.previousCursor = 0 + r.previousCursor = Position{} } // 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 int) { - r.move(cursor, 0) +func (r *Render) 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, n int) int { - return r.move(from, from-n) +func (r *Render) backward(from Position, n istrings.StringWidth) 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 int) int { - fromX, fromY := r.toPos(from) - toX, toY := r.toPos(to) - - r.out.CursorUp(fromY - toY) - r.out.CursorBackward(fromX - toX) +func (r *Render) move(from, to Position) Position { + newPosition := from.Subtract(to) + r.out.CursorUp(newPosition.Y) + r.out.CursorBackward(int(newPosition.X)) return to } -// toPos returns the relative position from the beginning of the string. -func (r *Render) toPos(cursor int) (x, y int) { - col := int(r.col) - return cursor % col, cursor / col -} - -func (r *Render) lineWrap(cursor int) { - if runtime.GOOS != "windows" && cursor > 0 && cursor%int(r.col) == 0 { +func (r *Render) lineWrap(cursor *Position) { + if runtime.GOOS != "windows" && cursor.X > 0 && cursor.X%r.col == 0 { + cursor.X = 0 + cursor.Y += 1 r.out.WriteRaw([]byte{'\n'}) } } @@ -334,8 +353,10 @@ func clamp(high, low, x float64) float64 { } } -func alignNextLine(r *Render, col int) { +func alignNextLine(r *Render, col istrings.StringWidth) { r.out.CursorDown(1) - r.out.WriteStr("\r") - r.out.CursorForward(col) + if _, err := r.out.WriteString("\r"); err != nil { + panic(err) + } + r.out.CursorForward(int(col)) } diff --git a/render_test.go b/render_test.go index 14edcd22..d67a7ef2 100644 --- a/render_test.go +++ b/render_test.go @@ -7,6 +7,8 @@ import ( "reflect" "syscall" "testing" + + istrings "github.com/elk-language/go-prompt/strings" ) func TestFormatCompletion(t *testing.T) { @@ -17,7 +19,7 @@ func TestFormatCompletion(t *testing.T) { suffix string expected []Suggest maxWidth int - expectedWidth int + expectedWidth istrings.StringWidth }{ { scenario: "", @@ -73,11 +75,10 @@ func TestFormatCompletion(t *testing.T) { func TestBreakLineCallback(t *testing.T) { var i int r := &Render{ - prefix: "> ", out: &PosixWriter{ fd: syscall.Stdin, // "write" to stdin just so we don't mess with the output of the tests }, - livePrefixCallback: func() (string, bool) { return "", false }, + prefixCallback: DefaultPrefixCallback, prefixTextColor: Blue, prefixBGColor: DefaultColor, inputTextColor: DefaultColor, diff --git a/shortcut.go b/shortcut.go index be8d4282..4c4c8691 100644 --- a/shortcut.go +++ b/shortcut.go @@ -1,12 +1,11 @@ package prompt -func dummyExecutor(in string) {} +func NoopExecutor(in string) {} // Input get the input data from the user and return it. -func Input(prefix string, completer Completer, opts ...Option) string { - pt := New(dummyExecutor, completer) +func Input(opts ...Option) string { + pt := New(NoopExecutor) pt.renderer.prefixTextColor = DefaultColor - pt.renderer.prefix = prefix for _, opt := range opts { if err := opt(pt); err != nil { @@ -15,29 +14,3 @@ func Input(prefix string, completer Completer, opts ...Option) string { } return pt.Input() } - -// Choose to the shortcut of input function to select from string array. -// Deprecated: Maybe anyone want to use this. -func Choose(prefix string, choices []string, opts ...Option) string { - completer := newChoiceCompleter(choices, FilterHasPrefix) - pt := New(dummyExecutor, completer) - pt.renderer.prefixTextColor = DefaultColor - pt.renderer.prefix = prefix - - for _, opt := range opts { - if err := opt(pt); err != nil { - panic(err) - } - } - return pt.Input() -} - -func newChoiceCompleter(choices []string, filter Filter) Completer { - s := make([]Suggest, len(choices)) - for i := range choices { - s[i] = Suggest{Text: choices[i]} - } - return func(x Document) []Suggest { - return filter(s, x.GetWordBeforeCursor(), true) - } -} diff --git a/signal_posix.go b/signal_posix.go index fe27a99a..b63c78fd 100644 --- a/signal_posix.go +++ b/signal_posix.go @@ -8,11 +8,11 @@ import ( "os/signal" "syscall" - "github.com/elk-language/go-prompt/internal/debug" + "github.com/elk-language/go-prompt/debug" ) func (p *Prompt) handleSignals(exitCh chan int, winSizeCh chan *WinSize, stop chan struct{}) { - in := p.in + in := p.reader sigCh := make(chan os.Signal, 1) signal.Notify( sigCh, diff --git a/signal_windows.go b/signal_windows.go index 2b79c39c..a1191332 100644 --- a/signal_windows.go +++ b/signal_windows.go @@ -8,7 +8,7 @@ import ( "os/signal" "syscall" - "github.com/elk-language/go-prompt/internal/debug" + "github.com/elk-language/go-prompt/debug" ) func (p *Prompt) handleSignals(exitCh chan int, winSizeCh chan *WinSize, stop chan struct{}) { diff --git a/internal/strings/strings.go b/strings/strings.go similarity index 73% rename from internal/strings/strings.go rename to strings/strings.go index f7d1886a..d89ec60f 100644 --- a/internal/strings/strings.go +++ b/strings/strings.go @@ -1,23 +1,35 @@ package strings -import "unicode/utf8" +import ( + "unicode/utf8" +) + +// Get the length of the string in bytes. +func Len(s string) ByteNumber { + return ByteNumber(len(s)) +} + +// Get the length of the string in runes. +func RuneCount(s string) RuneNumber { + return RuneNumber(utf8.RuneCountInString(s)) +} // IndexNotByte is similar with strings.IndexByte but showing the opposite behavior. -func IndexNotByte(s string, c byte) int { +func IndexNotByte(s string, c byte) ByteNumber { n := len(s) for i := 0; i < n; i++ { if s[i] != c { - return i + return ByteNumber(i) } } return -1 } // LastIndexNotByte is similar with strings.LastIndexByte but showing the opposite behavior. -func LastIndexNotByte(s string, c byte) int { +func LastIndexNotByte(s string, c byte) ByteNumber { for i := len(s) - 1; i >= 0; i-- { if s[i] != c { - return i + return ByteNumber(i) } } return -1 @@ -41,13 +53,13 @@ func makeASCIISet(chars string) (as asciiSet, ok bool) { } // IndexNotAny is similar with strings.IndexAny but showing the opposite behavior. -func IndexNotAny(s, chars string) int { +func IndexNotAny(s, chars string) ByteNumber { if len(chars) > 0 { if len(s) > 8 { if as, isASCII := makeASCIISet(chars); isASCII { for i := 0; i < len(s); i++ { if as.notContains(s[i]) { - return i + return ByteNumber(i) } } return -1 @@ -58,7 +70,7 @@ func IndexNotAny(s, chars string) int { for i, c := range s { for j, m := range chars { if c != m && j == len(chars)-1 { - return i + return ByteNumber(i) } else if c != m { continue } else { @@ -71,13 +83,13 @@ func IndexNotAny(s, chars string) int { } // LastIndexNotAny is similar with strings.LastIndexAny but showing the opposite behavior. -func LastIndexNotAny(s, chars string) int { +func LastIndexNotAny(s, chars string) ByteNumber { if len(chars) > 0 { if len(s) > 8 { if as, isASCII := makeASCIISet(chars); isASCII { for i := len(s) - 1; i >= 0; i-- { if as.notContains(s[i]) { - return i + return ByteNumber(i) } } return -1 @@ -89,7 +101,7 @@ func LastIndexNotAny(s, chars string) int { i -= size for j, m := range chars { if r != m && j == len(chars)-1 { - return i + return ByteNumber(i) } else if r != m { continue } else { diff --git a/internal/strings/strings_test.go b/strings/strings_test.go similarity index 94% rename from internal/strings/strings_test.go rename to strings/strings_test.go index 9782da0c..8266054c 100644 --- a/internal/strings/strings_test.go +++ b/strings/strings_test.go @@ -3,7 +3,7 @@ package strings_test import ( "fmt" - "github.com/elk-language/go-prompt/internal/strings" + "github.com/elk-language/go-prompt/strings" ) func ExampleIndexNotByte() { diff --git a/strings/units.go b/strings/units.go new file mode 100644 index 00000000..106b3432 --- /dev/null +++ b/strings/units.go @@ -0,0 +1,13 @@ +package strings + +// Numeric type that represents the amount +// of bytes in a string, array or slice. +type ByteNumber int + +// Numeric type that represents the amount +// of runes in a string, array or slice. +type RuneNumber int + +// Numeric type that represents the visible +// width of characters in a string as seen in a terminal emulator. +type StringWidth int diff --git a/internal/term/raw.go b/term/raw.go similarity index 100% rename from internal/term/raw.go rename to term/raw.go diff --git a/internal/term/term.go b/term/term.go similarity index 100% rename from internal/term/term.go rename to term/term.go diff --git a/output.go b/writer.go similarity index 90% rename from output.go rename to writer.go index 42531b41..7d1df825 100644 --- a/output.go +++ b/writer.go @@ -1,13 +1,16 @@ package prompt -import "sync" +import ( + "io" + "sync" +) var ( consoleWriterMu sync.Mutex - consoleWriter ConsoleWriter + consoleWriter Writer ) -func registerConsoleWriter(f ConsoleWriter) { +func registerWriter(f Writer) { consoleWriterMu.Lock() defer consoleWriterMu.Unlock() consoleWriter = f @@ -87,18 +90,16 @@ const ( White ) -// ConsoleWriter is an interface to abstract output layer. -type ConsoleWriter interface { +// Writer is an interface to abstract the output layer. +type Writer interface { /* Write */ + io.Writer + io.StringWriter // WriteRaw to write raw byte array. WriteRaw(data []byte) - // Write to write safety byte array by removing control sequences. - Write(data []byte) - // WriteStr to write raw string. - WriteRawStr(data string) - // WriteStr to write safety string by removing control sequences. - WriteStr(data string) + // WriteString to write raw string. + WriteRawString(data string) // Flush to flush buffer. Flush() error diff --git a/output_posix.go b/writer_posix.go similarity index 74% rename from output_posix.go rename to writer_posix.go index 26c770cc..207da8ca 100644 --- a/output_posix.go +++ b/writer_posix.go @@ -1,3 +1,4 @@ +//go:build !windows // +build !windows package prompt @@ -8,7 +9,7 @@ import ( const flushMaxRetryCount = 3 -// PosixWriter is a ConsoleWriter implementation for POSIX environment. +// PosixWriter is a Writer implementation for POSIX environment. // To control terminal emulator, this outputs VT100 escape sequences. type PosixWriter struct { VT100Writer @@ -38,29 +39,29 @@ func (w *PosixWriter) Flush() error { return nil } -var _ ConsoleWriter = &PosixWriter{} +var _ Writer = &PosixWriter{} var ( - // NewStandardOutputWriter returns ConsoleWriter object to write to stdout. + // NewStandardOutputWriter returns Writer object to write to stdout. // This generates VT100 escape sequences because almost terminal emulators // in POSIX OS built on top of a VT100 specification. // Deprecated: Please use NewStdoutWriter NewStandardOutputWriter = NewStdoutWriter ) -// NewStdoutWriter returns ConsoleWriter object to write to stdout. +// NewStdoutWriter returns Writer object to write to stdout. // This generates VT100 escape sequences because almost terminal emulators // in POSIX OS built on top of a VT100 specification. -func NewStdoutWriter() ConsoleWriter { +func NewStdoutWriter() Writer { return &PosixWriter{ fd: syscall.Stdout, } } -// NewStderrWriter returns ConsoleWriter object to write to stderr. +// NewStderrWriter returns Writer object to write to stderr. // This generates VT100 escape sequences because almost terminal emulators // in POSIX OS built on top of a VT100 specification. -func NewStderrWriter() ConsoleWriter { +func NewStderrWriter() Writer { return &PosixWriter{ fd: syscall.Stderr, } diff --git a/output_vt100.go b/writer_vt100.go similarity index 94% rename from output_vt100.go rename to writer_vt100.go index 894b858e..7da31c21 100644 --- a/output_vt100.go +++ b/writer_vt100.go @@ -2,6 +2,7 @@ package prompt import ( "bytes" + "io" "strconv" ) @@ -10,26 +11,30 @@ type VT100Writer struct { buffer []byte } +var _ io.Writer = &VT100Writer{} +var _ io.StringWriter = &VT100Writer{} + +// Write to write safety byte array by removing control sequences. +func (w *VT100Writer) Write(data []byte) (int, error) { + w.WriteRaw(bytes.Replace(data, []byte{0x1b}, []byte{'?'}, -1)) + return len(data), nil +} + // WriteRaw to write raw byte array func (w *VT100Writer) WriteRaw(data []byte) { w.buffer = append(w.buffer, data...) } -// Write to write safety byte array by removing control sequences. -func (w *VT100Writer) Write(data []byte) { - w.WriteRaw(bytes.Replace(data, []byte{0x1b}, []byte{'?'}, -1)) +// WriteString to write safety string by removing control sequences. +func (w *VT100Writer) WriteString(data string) (int, error) { + return w.Write([]byte(data)) } -// WriteRawStr to write raw string -func (w *VT100Writer) WriteRawStr(data string) { +// WriteRawString to write raw string +func (w *VT100Writer) WriteRawString(data string) { w.WriteRaw([]byte(data)) } -// WriteStr to write safety string by removing control sequences. -func (w *VT100Writer) WriteStr(data string) { - w.Write([]byte(data)) -} - /* Erase */ // EraseScreen erases the screen with the background colour and moves the cursor to home. diff --git a/output_vt100_test.go b/writer_vt100_test.go similarity index 81% rename from output_vt100_test.go rename to writer_vt100_test.go index 591607ca..b0ea1c1e 100644 --- a/output_vt100_test.go +++ b/writer_vt100_test.go @@ -22,7 +22,9 @@ func TestVT100WriterWrite(t *testing.T) { for _, s := range scenarioTable { pw := &VT100Writer{} - pw.Write(s.input) + if _, err := pw.Write(s.input); err != nil { + panic(err) + } if !bytes.Equal(pw.buffer, s.expected) { t.Errorf("Should be %+#v, but got %+#v", pw.buffer, s.expected) @@ -30,7 +32,7 @@ func TestVT100WriterWrite(t *testing.T) { } } -func TestVT100WriterWriteStr(t *testing.T) { +func TestVT100WriterWriteString(t *testing.T) { scenarioTable := []struct { input string expected []byte @@ -47,7 +49,9 @@ func TestVT100WriterWriteStr(t *testing.T) { for _, s := range scenarioTable { pw := &VT100Writer{} - pw.WriteStr(s.input) + if _, err := pw.WriteString(s.input); err != nil { + panic(err) + } if !bytes.Equal(pw.buffer, s.expected) { t.Errorf("Should be %+#v, but got %+#v", pw.buffer, s.expected) @@ -55,7 +59,7 @@ func TestVT100WriterWriteStr(t *testing.T) { } } -func TestVT100WriterWriteRawStr(t *testing.T) { +func TestVT100WriterWriteRawString(t *testing.T) { scenarioTable := []struct { input string expected []byte @@ -72,7 +76,7 @@ func TestVT100WriterWriteRawStr(t *testing.T) { for _, s := range scenarioTable { pw := &VT100Writer{} - pw.WriteRawStr(s.input) + pw.WriteRawString(s.input) if !bytes.Equal(pw.buffer, s.expected) { t.Errorf("Should be %+#v, but got %+#v", pw.buffer, s.expected) diff --git a/output_windows.go b/writer_windows.go similarity index 69% rename from output_windows.go rename to writer_windows.go index 3b9c2769..b6a44f67 100644 --- a/output_windows.go +++ b/writer_windows.go @@ -1,3 +1,4 @@ +//go:build windows // +build windows package prompt @@ -8,7 +9,7 @@ import ( colorable "github.com/mattn/go-colorable" ) -// WindowsWriter is a ConsoleWriter implementation for Win32 console. +// WindowsWriter is a Writer implementation for Win32 console. // Output is converted from VT100 escape sequences by mattn/go-colorable. type WindowsWriter struct { VT100Writer @@ -25,24 +26,24 @@ func (w *WindowsWriter) Flush() error { return nil } -var _ ConsoleWriter = &WindowsWriter{} +var _ Writer = &WindowsWriter{} var ( // NewStandardOutputWriter is Deprecated: Please use NewStdoutWriter NewStandardOutputWriter = NewStdoutWriter ) -// NewStdoutWriter returns ConsoleWriter object to write to stdout. +// NewStdoutWriter returns Writer object to write to stdout. // This generates win32 control sequences. -func NewStdoutWriter() ConsoleWriter { +func NewStdoutWriter() Writer { return &WindowsWriter{ out: colorable.NewColorableStdout(), } } -// NewStderrWriter returns ConsoleWriter object to write to stderr. +// NewStderrWriter returns Writer object to write to stderr. // This generates win32 control sequences. -func NewStderrWriter() ConsoleWriter { +func NewStderrWriter() Writer { return &WindowsWriter{ out: colorable.NewColorableStderr(), }