diff --git a/.github/workflows/golangci.yml b/.github/workflows/golangci.yml index 558b850b3..57428ec3a 100644 --- a/.github/workflows/golangci.yml +++ b/.github/workflows/golangci.yml @@ -1,3 +1,5 @@ + + name: golangci-lint on: [ push, pull_request ] jobs: @@ -10,10 +12,9 @@ jobs: - name: Set up Go uses: actions/setup-go@v2 with: - go-version: 1.16 + go-version: 1.18 - name: golangci-lint uses: golangci/golangci-lint-action@v3 with: - # Required: the version of golangci-lint is required and must be specified without patch version: we always use the latest patch version. - version: v1.39 + version: latest diff --git a/_examples/box/demo/main.go b/_examples/box/demo/main.go index f25a80e3a..52fc9e256 100644 --- a/_examples/box/demo/main.go +++ b/_examples/box/demo/main.go @@ -3,7 +3,7 @@ package main import "github.com/pterm/pterm" func main() { - pterm.Info.Println("This might not be rendered correctly on GitHub,\nbut it will work in a real terminal.\nThis is because GitHub does not use a monospaced font by default for SVGs.") + pterm.Info.Println("This might not be rendered correctly on GitHub,\nbut it will work in a real terminal.\nThis is because GitHub does not use a monospaced font by default for SVGs") panel1 := pterm.DefaultBox.Sprint("Lorem ipsum dolor sit amet,\nconsectetur adipiscing elit,\nsed do eiusmod tempor incididunt\nut labore et dolore\nmagna aliqua.") panel2 := pterm.DefaultBox.WithTitle("title").Sprint("Ut enim ad minim veniam,\nquis nostrud exercitation\nullamco laboris\nnisi ut aliquip\nex ea commodo\nconsequat.") diff --git a/_examples/interactive_confirm/demo/ci.go b/_examples/interactive_confirm/demo/ci.go new file mode 100644 index 000000000..2ba46081b --- /dev/null +++ b/_examples/interactive_confirm/demo/ci.go @@ -0,0 +1,19 @@ +package main + +import ( + "os" + "time" + + "atomicgo.dev/keyboard" +) + +// ------ Automation for CI ------ +// You can ignore this function, it is used to automatically run the demo and generate the example animation in our CI system. +func init() { + if os.Getenv("CI") == "true" { + go func() { + time.Sleep(time.Second * 2) + keyboard.SimulateKeyPress('y') + }() + } +} diff --git a/_examples/interactive_confirm/demo/main.go b/_examples/interactive_confirm/demo/main.go new file mode 100644 index 000000000..9c27aa0ca --- /dev/null +++ b/_examples/interactive_confirm/demo/main.go @@ -0,0 +1,18 @@ +package main + +import ( + "github.com/pterm/pterm" +) + +func main() { + result, _ := pterm.DefaultInteractiveConfirm.Show() + pterm.Println() // Blank line + pterm.Info.Printfln("You answered: %s", boolToText(result)) +} + +func boolToText(b bool) string { + if b { + return pterm.Green("Yes") + } + return pterm.Red("No") +} diff --git a/_examples/interactive_multiselect/demo/ci.go b/_examples/interactive_multiselect/demo/ci.go new file mode 100644 index 000000000..f8e4cd084 --- /dev/null +++ b/_examples/interactive_multiselect/demo/ci.go @@ -0,0 +1,44 @@ +package main + +import ( + "os" + "time" + + "atomicgo.dev/keyboard" + "atomicgo.dev/keyboard/keys" +) + +// ------ Automation for CI ------ +// You can ignore this function, it is used to automatically run the demo and generate the example animation in our CI system. +func init() { + if os.Getenv("CI") == "true" { + go func() { + time.Sleep(time.Second) + for i := 0; i < 10; i++ { + keyboard.SimulateKeyPress(keys.Down) + if i%2 == 0 { + time.Sleep(time.Millisecond * 100) + keyboard.SimulateKeyPress(keys.Enter) + } + time.Sleep(time.Millisecond * 500) + } + time.Sleep(time.Second) + + for _, s := range "fuzzy" { + keyboard.SimulateKeyPress(s) + time.Sleep(time.Millisecond * 150) + } + + time.Sleep(time.Second) + + for i := 0; i < 2; i++ { + keyboard.SimulateKeyPress(keys.Down) + time.Sleep(time.Millisecond * 300) + } + + keyboard.SimulateKeyPress(keys.Enter) + time.Sleep(time.Millisecond * 350) + keyboard.SimulateKeyPress(keys.Tab) + }() + } +} diff --git a/_examples/interactive_multiselect/demo/main.go b/_examples/interactive_multiselect/demo/main.go new file mode 100644 index 000000000..3f119ec41 --- /dev/null +++ b/_examples/interactive_multiselect/demo/main.go @@ -0,0 +1,22 @@ +package main + +import ( + "fmt" + + "github.com/pterm/pterm" +) + +func main() { + var options []string + + for i := 0; i < 100; i++ { + options = append(options, fmt.Sprintf("Option %d", i)) + } + + for i := 0; i < 5; i++ { + options = append(options, fmt.Sprintf("You can use fuzzy searching (%d)", i)) + } + + selectedOptions, _ := pterm.DefaultInteractiveMultiselect.WithOptions(options).Show() + pterm.Info.Printfln("Selected options: %s", pterm.Green(selectedOptions)) +} diff --git a/_examples/interactive_select/demo/ci.go b/_examples/interactive_select/demo/ci.go new file mode 100644 index 000000000..434b4f4a5 --- /dev/null +++ b/_examples/interactive_select/demo/ci.go @@ -0,0 +1,38 @@ +package main + +import ( + "os" + "time" + + "atomicgo.dev/keyboard" + "atomicgo.dev/keyboard/keys" +) + +// ------ Automation for CI ------ +// You can ignore this function, it is used to automatically run the demo and generate the example animation in our CI system. +func init() { + if os.Getenv("CI") == "true" { + go func() { + time.Sleep(time.Second) + for i := 0; i < 10; i++ { + keyboard.SimulateKeyPress(keys.Down) + time.Sleep(time.Millisecond * 250) + } + time.Sleep(time.Second) + + for _, s := range "fuzzy" { + keyboard.SimulateKeyPress(s) + time.Sleep(time.Millisecond * 150) + } + + time.Sleep(time.Second) + + for i := 0; i < 2; i++ { + keyboard.SimulateKeyPress(keys.Down) + time.Sleep(time.Millisecond * 300) + } + + keyboard.SimulateKeyPress(keys.Enter) + }() + } +} diff --git a/_examples/interactive_select/demo/main.go b/_examples/interactive_select/demo/main.go new file mode 100644 index 000000000..705cbb3f0 --- /dev/null +++ b/_examples/interactive_select/demo/main.go @@ -0,0 +1,22 @@ +package main + +import ( + "fmt" + + "github.com/pterm/pterm" +) + +func main() { + var options []string + + for i := 0; i < 100; i++ { + options = append(options, fmt.Sprintf("Option %d", i)) + } + + for i := 0; i < 5; i++ { + options = append(options, fmt.Sprintf("You can use fuzzy searching (%d)", i)) + } + + selectedOption, _ := pterm.DefaultInteractiveSelect.WithOptions(options).Show() + pterm.Info.Printfln("Selected option: %s", pterm.Green(selectedOption)) +} diff --git a/_examples/interactive_textinput/demo/ci.go b/_examples/interactive_textinput/demo/ci.go new file mode 100644 index 000000000..9c99622b1 --- /dev/null +++ b/_examples/interactive_textinput/demo/ci.go @@ -0,0 +1,35 @@ +package main + +import ( + "os" + "time" + + "atomicgo.dev/keyboard" + "atomicgo.dev/keyboard/keys" +) + +// ------ Automation for CI ------ +// You can ignore this function, it is used to automatically run the demo and generate the example animation in our CI system. +func init() { + if os.Getenv("CI") == "true" { + go func() { + time.Sleep(time.Second) + input := "Hello; World!" + for _, r := range []rune(input) { + keyboard.SimulateKeyPress(r) + time.Sleep(time.Millisecond * 250) + } + + for i := 0; i < 7; i++ { + keyboard.SimulateKeyPress(keys.Left) + time.Sleep(time.Millisecond * 150) + } + + keyboard.SimulateKeyPress(keys.Backspace) + time.Sleep(time.Millisecond * 500) + keyboard.SimulateKeyPress(',') + time.Sleep(time.Millisecond * 500) + keyboard.SimulateKeyPress(keys.Enter) + }() + } +} diff --git a/_examples/interactive_textinput/demo/main.go b/_examples/interactive_textinput/demo/main.go new file mode 100644 index 000000000..ac00eaf1c --- /dev/null +++ b/_examples/interactive_textinput/demo/main.go @@ -0,0 +1,11 @@ +package main + +import ( + "github.com/pterm/pterm" +) + +func main() { + result, _ := pterm.DefaultInteractiveTextInput.WithMultiLine(false).Show() + pterm.Println() // Blank line + pterm.Info.Printfln("You answered: %s", result) +} diff --git a/_examples/interactive_textinput/multi-line/ci.go b/_examples/interactive_textinput/multi-line/ci.go new file mode 100644 index 000000000..723ef7715 --- /dev/null +++ b/_examples/interactive_textinput/multi-line/ci.go @@ -0,0 +1,49 @@ +package main + +import ( + "os" + "time" + + "atomicgo.dev/keyboard" + "atomicgo.dev/keyboard/keys" +) + +// ------ Automation for CI ------ +// You can ignore this function, it is used to automatically run the demo and generate the example animation in our CI system. +func init() { + if os.Getenv("CI") == "true" { + go func() { + time.Sleep(time.Second) + input := "1111111\n2222222" + for _, r := range input { + if r == '\n' { + keyboard.SimulateKeyPress(keys.Enter) + } else { + keyboard.SimulateKeyPress(r) + } + time.Sleep(time.Millisecond * 250) + } + + for i := 0; i < 7; i++ { + keyboard.SimulateKeyPress(keys.Left) + time.Sleep(time.Millisecond * 150) + } + + keyboard.SimulateKeyPress(keys.Backspace) + time.Sleep(time.Millisecond * 500) + keyboard.SimulateKeyPress(keys.Enter) + time.Sleep(time.Millisecond * 500) + input = "33333333\n4\n5555555" + for _, r := range input { + if r == '\n' { + keyboard.SimulateKeyPress(keys.Enter) + } else { + keyboard.SimulateKeyPress(r) + } + time.Sleep(time.Millisecond * 250) + } + + keyboard.SimulateKeyPress(keys.Tab) + }() + } +} diff --git a/_examples/interactive_textinput/multi-line/main.go b/_examples/interactive_textinput/multi-line/main.go new file mode 100644 index 000000000..361192e6f --- /dev/null +++ b/_examples/interactive_textinput/multi-line/main.go @@ -0,0 +1,11 @@ +package main + +import ( + "github.com/pterm/pterm" +) + +func main() { + result, _ := pterm.DefaultInteractiveTextInput.WithMultiLine().Show() // Text input with multi line enabled + pterm.Println() // Blank line + pterm.Info.Printfln("You answered: %s", result) +} diff --git a/area_printer.go b/area_printer.go index d30410363..9fe5a7197 100644 --- a/area_printer.go +++ b/area_printer.go @@ -3,7 +3,7 @@ package pterm import ( "strings" - "github.com/atomicgo/cursor" + "atomicgo.dev/cursor" "github.com/pterm/pterm/internal" ) diff --git a/ci/main.go b/ci/main.go index 1ebd8bb0a..09bdcd6f8 100644 --- a/ci/main.go +++ b/ci/main.go @@ -15,13 +15,13 @@ import ( ) type Examples struct { - sync.Mutex - m map[string]string + mu sync.Mutex + m map[string]string } func (e *Examples) Add(name, content string) { - e.Lock() - defer e.Unlock() + e.mu.Lock() + defer e.mu.Unlock() e.m[name] = content } diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 000000000..bfdc9877d --- /dev/null +++ b/codecov.yml @@ -0,0 +1,8 @@ +coverage: + status: + project: + default: + informational: true + patch: + default: + informational: true diff --git a/color.go b/color.go index b1ef19b8b..3626ee475 100644 --- a/color.go +++ b/color.go @@ -212,32 +212,32 @@ func (c Color) Printfln(format string, a ...interface{}) *TextPrinter { // PrintOnError prints every error which is not nil. // If every error is nil, nothing will be printed. // This can be used for simple error checking. -func (p Color) PrintOnError(a ...interface{}) *TextPrinter { +func (c Color) PrintOnError(a ...interface{}) *TextPrinter { for _, arg := range a { if err, ok := arg.(error); ok { if err != nil { - p.Println(err) + c.Println(err) } } } - tp := TextPrinter(p) + tp := TextPrinter(c) return &tp } // PrintOnErrorf wraps every error which is not nil and prints it. // If every error is nil, nothing will be printed. // This can be used for simple error checking. -func (p Color) PrintOnErrorf(format string, a ...interface{}) *TextPrinter { +func (c Color) PrintOnErrorf(format string, a ...interface{}) *TextPrinter { for _, arg := range a { if err, ok := arg.(error); ok { if err != nil { - p.Println(fmt.Errorf(format, err)) + c.Println(fmt.Errorf(format, err)) } } } - tp := TextPrinter(p) + tp := TextPrinter(c) return &tp } diff --git a/go.mod b/go.mod index 58dbc73cd..6311b3ee8 100644 --- a/go.mod +++ b/go.mod @@ -3,9 +3,11 @@ module github.com/pterm/pterm go 1.15 require ( - github.com/MarvinJWendt/testza v0.3.5 - github.com/atomicgo/cursor v0.0.1 + atomicgo.dev/cursor v0.1.1 + atomicgo.dev/keyboard v0.2.8 + github.com/MarvinJWendt/testza v0.4.2 github.com/gookit/color v1.5.0 + github.com/lithammer/fuzzysearch v1.1.5 github.com/mattn/go-runewidth v0.0.13 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 ) diff --git a/go.sum b/go.sum index 41ce65452..38826b3b4 100644 --- a/go.sum +++ b/go.sum @@ -1,13 +1,18 @@ +atomicgo.dev/cursor v0.1.1 h1:0t9sxQomCTRh5ug+hAMCs59x/UmC9QL6Ci5uosINKD4= +atomicgo.dev/cursor v0.1.1/go.mod h1:Lr4ZJB3U7DfPPOkbH7/6TOtJ4vFGHlgj1nc+n900IpU= +atomicgo.dev/keyboard v0.2.8 h1:Di09BitwZgdTV1hPyX/b9Cqxi8HVuJQwWivnZUEqlj4= +atomicgo.dev/keyboard v0.2.8/go.mod h1:BC4w9g00XkxH/f1HXhW2sXmJFOCWbKn9xrOunSFtExQ= github.com/MarvinJWendt/testza v0.1.0/go.mod h1:7AxNvlfeHP7Z/hDQ5JtE3OKYT3XFUeLCDE2DQninSqs= github.com/MarvinJWendt/testza v0.2.1/go.mod h1:God7bhG8n6uQxwdScay+gjm9/LnO4D3kkcZX4hv9Rp8= github.com/MarvinJWendt/testza v0.2.8/go.mod h1:nwIcjmr0Zz+Rcwfh3/4UhBp7ePKVhuBExvZqnKYWlII= github.com/MarvinJWendt/testza v0.2.10/go.mod h1:pd+VWsoGUiFtq+hRKSU1Bktnn+DMCSrDrXDpX2bG66k= github.com/MarvinJWendt/testza v0.2.12/go.mod h1:JOIegYyV7rX+7VZ9r77L/eH6CfJHHzXjB69adAhzZkI= github.com/MarvinJWendt/testza v0.3.0/go.mod h1:eFcL4I0idjtIx8P9C6KkAuLgATNKpX4/2oUqKc6bF2c= -github.com/MarvinJWendt/testza v0.3.5 h1:g9krITRRlIsF1eO9sUKXtiTw670gZIIk6T08Keeo1nM= -github.com/MarvinJWendt/testza v0.3.5/go.mod h1:ExbTpWmA1z2E9HSskvrNcwApoX4F9bID692s10nuHRY= -github.com/atomicgo/cursor v0.0.1 h1:xdogsqa6YYlLfM+GyClC/Lchf7aiMerFiZQn7soTOoU= +github.com/MarvinJWendt/testza v0.4.2 h1:Vbw9GkSB5erJI2BPnBL9SVGV9myE+XmUSFahBGUhW2Q= +github.com/MarvinJWendt/testza v0.4.2/go.mod h1:mSdhXiKH8sg/gQehJ63bINcCKp7RtYewEjXsvsVUPbE= github.com/atomicgo/cursor v0.0.1/go.mod h1:cBON2QmmrysudxNBFthvMtN32r3jxVRIvzkUiF/RuIk= +github.com/containerd/console v1.0.3 h1:lIr7SlA5PxZyMV30bDW0MGbiOPXwc63yRuCP0ARubLw= +github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -23,6 +28,8 @@ github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORN github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/lithammer/fuzzysearch v1.1.5 h1:Ag7aKU08wp0R9QCfF4GoGST9HbmAIeLP7xwMrOBEp1c= +github.com/lithammer/fuzzysearch v1.1.5/go.mod h1:1R1LRNk7yKid1BaQkmuLQaHruxcC4HmAH30Dh61Ih1Q= github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU= github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -46,6 +53,7 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778 h1:QldyIu/L63oPpyvQmHgvgickp1Yw510KJOqX7H24mg8= github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778/go.mod h1:2MuV+tbUrU1zIOPMxZ5EncGwgmMJsa+9ucAQZXxsObs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211013075003-97ac67df715c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -55,6 +63,9 @@ golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d/go.mod h1:bj7SfCRtBDWHUb9sn golang.org/x/term v0.0.0-20210615171337-6886f2dfbf5b/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 h1:JGgROgKl9N8DuW20oFS5gxc+lE67/N3FcwmBPMe7ArY= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/interactive_confirm_printer.go b/interactive_confirm_printer.go new file mode 100644 index 000000000..cbfa53692 --- /dev/null +++ b/interactive_confirm_printer.go @@ -0,0 +1,154 @@ +package pterm + +import ( + "fmt" + "os" + + "atomicgo.dev/cursor" + "atomicgo.dev/keyboard" + "atomicgo.dev/keyboard/keys" +) + +var ( + // DefaultInteractiveConfirm is the default InteractiveConfirm printer. + // Pressing "y" will return true, "n" will return false. + // Pressing enter without typing "y" or "n" will return the configured default value (by default set to "no"). + DefaultInteractiveConfirm = InteractiveConfirmPrinter{ + DefaultValue: false, + DefaultText: "Please confirm", + TextStyle: &ThemeDefault.PrimaryStyle, + ConfirmText: "Yes", + ConfirmStyle: &ThemeDefault.SuccessMessageStyle, + RejectText: "No", + RejectStyle: &ThemeDefault.ErrorMessageStyle, + SuffixStyle: &ThemeDefault.SecondaryStyle, + } +) + +// InteractiveConfirmPrinter is a printer for interactive confirm prompts. +type InteractiveConfirmPrinter struct { + DefaultValue bool + DefaultText string + TextStyle *Style + ConfirmText string + ConfirmStyle *Style + RejectText string + RejectStyle *Style + SuffixStyle *Style +} + +// WithDefaultText sets the default text. +func (p InteractiveConfirmPrinter) WithDefaultText(text string) *InteractiveConfirmPrinter { + p.DefaultText = text + return &p +} + +// WithDefaultValue sets the default value, which will be returned when the user presses enter without typing "y" or "n". +func (p InteractiveConfirmPrinter) WithDefaultValue(value bool) *InteractiveConfirmPrinter { + p.DefaultValue = value + return &p +} + +// WithTextStyle sets the text style. +func (p InteractiveConfirmPrinter) WithTextStyle(style *Style) *InteractiveConfirmPrinter { + p.TextStyle = style + return &p +} + +// WithConfirmText sets the confirm text. +func (p InteractiveConfirmPrinter) WithConfirmText(text string) *InteractiveConfirmPrinter { + p.ConfirmText = text + return &p +} + +// WithConfirmStyle sets the confirm style. +func (p InteractiveConfirmPrinter) WithConfirmStyle(style *Style) *InteractiveConfirmPrinter { + p.ConfirmStyle = style + return &p +} + +// WithRejectText sets the reject text. +func (p InteractiveConfirmPrinter) WithRejectText(text string) *InteractiveConfirmPrinter { + p.RejectText = text + return &p +} + +// WithRejectStyle sets the reject style. +func (p InteractiveConfirmPrinter) WithRejectStyle(style *Style) *InteractiveConfirmPrinter { + p.RejectStyle = style + return &p +} + +// WithSuffixStyle sets the suffix style. +func (p InteractiveConfirmPrinter) WithSuffixStyle(style *Style) *InteractiveConfirmPrinter { + p.SuffixStyle = style + return &p +} + +// Show shows the confirm prompt. +// +// Example: +// result, _ := pterm.DefaultInteractiveConfirm.Show("Are you sure?") +// pterm.Println(result) +func (p InteractiveConfirmPrinter) Show(text ...string) (bool, error) { + var result bool + + if len(text) == 0 || text[0] == "" { + text = []string{p.DefaultText} + } + + p.TextStyle.Print(text[0] + " " + p.getSuffix() + ": ") + + err := keyboard.Listen(func(keyInfo keys.Key) (stop bool, err error) { + key := keyInfo.Code + char := keyInfo.String() + if err != nil { + return false, fmt.Errorf("failed to get key: %w", err) + } + + switch key { + case keys.RuneKey: + switch char { + case "y", "Y": + p.ConfirmStyle.Print(p.ConfirmText) + Println() + result = true + return true, nil + case "n", "N": + p.RejectStyle.Print(p.RejectText) + Println() + result = false + return true, nil + } + case keys.Enter: + if p.DefaultValue { + p.ConfirmStyle.Print(p.ConfirmText) + } else { + p.RejectStyle.Print(p.RejectText) + } + Println() + result = p.DefaultValue + return true, nil + case keys.CtrlC: + os.Exit(1) + return true, nil + } + return false, nil + }) + cursor.StartOfLine() + return result, err +} + +func (p InteractiveConfirmPrinter) getSuffix() string { + var y string + var n string + if p.DefaultValue { + y = "Y" + n = "n" + } else { + y = "y" + n = "N" + } + + return p.SuffixStyle.Sprintf("[%s/%s]", y, n) +} diff --git a/interactive_confirm_printer_test.go b/interactive_confirm_printer_test.go new file mode 100644 index 000000000..ec6dbb963 --- /dev/null +++ b/interactive_confirm_printer_test.go @@ -0,0 +1,89 @@ +package pterm_test + +import ( + "testing" + + "atomicgo.dev/keyboard" + "atomicgo.dev/keyboard/keys" + "github.com/MarvinJWendt/testza" + + "github.com/pterm/pterm" +) + +func TestInteractiveConfirmPrinter_Show_yes(t *testing.T) { + go func() { + keyboard.SimulateKeyPress('y') + }() + result, _ := pterm.DefaultInteractiveConfirm.Show() + testza.AssertTrue(t, result) +} + +func TestInteractiveConfirmPrinter_Show_no(t *testing.T) { + go func() { + keyboard.SimulateKeyPress('n') + }() + result, _ := pterm.DefaultInteractiveConfirm.Show() + testza.AssertFalse(t, result) +} + +func TestInteractiveConfirmPrinter_WithDefaultValue(t *testing.T) { + p := pterm.DefaultInteractiveConfirm.WithDefaultValue(true) + testza.AssertTrue(t, p.DefaultValue) +} + +func TestInteractiveConfirmPrinter_WithDefaultValue_false(t *testing.T) { + go func() { + keyboard.SimulateKeyPress(keys.Enter) + }() + p := pterm.DefaultInteractiveConfirm.WithDefaultValue(false) + result, _ := p.Show() + testza.AssertFalse(t, result) +} + +func TestInteractiveConfirmPrinter_WithDefaultValue_true(t *testing.T) { + go func() { + keyboard.SimulateKeyPress(keys.Enter) + }() + p := pterm.DefaultInteractiveConfirm.WithDefaultValue(true) + result, _ := p.Show() + testza.AssertTrue(t, result) +} + +func TestInteractiveConfirmPrinter_WithConfirmStyle(t *testing.T) { + style := pterm.NewStyle(pterm.FgRed) + p := pterm.DefaultInteractiveConfirm.WithConfirmStyle(style) + testza.AssertEqual(t, p.ConfirmStyle, style) +} + +func TestInteractiveConfirmPrinter_WithConfirmText(t *testing.T) { + p := pterm.DefaultInteractiveConfirm.WithConfirmText("confirm") + testza.AssertEqual(t, p.ConfirmText, "confirm") +} + +func TestInteractiveConfirmPrinter_WithDefaultText(t *testing.T) { + p := pterm.DefaultInteractiveConfirm.WithDefaultText("default") + testza.AssertEqual(t, p.DefaultText, "default") +} + +func TestInteractiveConfirmPrinter_WithRejectStyle(t *testing.T) { + style := pterm.NewStyle(pterm.FgRed) + p := pterm.DefaultInteractiveConfirm.WithRejectStyle(style) + testza.AssertEqual(t, p.RejectStyle, style) +} + +func TestInteractiveConfirmPrinter_WithRejectText(t *testing.T) { + p := pterm.DefaultInteractiveConfirm.WithRejectText("reject") + testza.AssertEqual(t, p.RejectText, "reject") +} + +func TestInteractiveConfirmPrinter_WithSuffixStyle(t *testing.T) { + style := pterm.NewStyle(pterm.FgRed) + p := pterm.DefaultInteractiveConfirm.WithSuffixStyle(style) + testza.AssertEqual(t, p.SuffixStyle, style) +} + +func TestInteractiveConfirmPrinter_WithTextStyle(t *testing.T) { + style := pterm.NewStyle(pterm.FgRed) + p := pterm.DefaultInteractiveConfirm.WithTextStyle(style) + testza.AssertEqual(t, p.TextStyle, style) +} diff --git a/interactive_multiselect_printer.go b/interactive_multiselect_printer.go new file mode 100644 index 000000000..b1ab8fec7 --- /dev/null +++ b/interactive_multiselect_printer.go @@ -0,0 +1,322 @@ +package pterm + +import ( + "fmt" + "os" + "sort" + + "atomicgo.dev/cursor" + "atomicgo.dev/keyboard" + "atomicgo.dev/keyboard/keys" + "github.com/lithammer/fuzzysearch/fuzzy" +) + +var ( + // DefaultInteractiveMultiselect is the default InteractiveMultiselect printer. + DefaultInteractiveMultiselect = InteractiveMultiselectPrinter{ + TextStyle: &ThemeDefault.PrimaryStyle, + DefaultText: "Please select your options", + Options: []string{}, + OptionStyle: &ThemeDefault.DefaultText, + DefaultOptions: []string{}, + MaxHeight: 5, + Selector: ">", + SelectorStyle: &ThemeDefault.SecondaryStyle, + } +) + +// InteractiveMultiselectPrinter is a printer for interactive multiselect menus. +type InteractiveMultiselectPrinter struct { + DefaultText string + TextStyle *Style + Options []string + OptionStyle *Style + DefaultOptions []string + MaxHeight int + Selector string + SelectorStyle *Style + + selectedOption int + selectedOptions []int + text string + fuzzySearchString string + fuzzySearchMatches []string + displayedOptions []string + displayedOptionsStart int + displayedOptionsEnd int +} + +// WithOptions sets the options. +func (p InteractiveMultiselectPrinter) WithOptions(options []string) *InteractiveMultiselectPrinter { + p.Options = options + return &p +} + +// WithDefaultOptions sets the default options. +func (p InteractiveMultiselectPrinter) WithDefaultOptions(options []string) *InteractiveMultiselectPrinter { + p.DefaultOptions = options + return &p +} + +// WithDefaultText sets the default text. +func (p InteractiveMultiselectPrinter) WithDefaultText(text string) *InteractiveMultiselectPrinter { + p.DefaultText = text + return &p +} + +// WithMaxHeight sets the maximum height of the select menu. +func (p InteractiveMultiselectPrinter) WithMaxHeight(maxHeight int) *InteractiveMultiselectPrinter { + p.MaxHeight = maxHeight + return &p +} + +// Show shows the interactive multiselect menu and returns the selected entry. +func (p *InteractiveMultiselectPrinter) Show(text ...string) ([]string, error) { + if len(text) == 0 || Sprint(text[0]) == "" { + text = []string{p.DefaultText} + } + + p.text = p.TextStyle.Sprint(text[0]) + p.fuzzySearchMatches = append([]string{}, p.Options...) + + if p.MaxHeight == 0 { + p.MaxHeight = DefaultInteractiveMultiselect.MaxHeight + } + + maxHeight := p.MaxHeight + if maxHeight > len(p.fuzzySearchMatches) { + maxHeight = len(p.fuzzySearchMatches) + } + + if len(p.Options) == 0 { + return nil, fmt.Errorf("no options provided") + } + + p.displayedOptions = append([]string{}, p.fuzzySearchMatches[:maxHeight]...) + p.displayedOptionsStart = 0 + p.displayedOptionsEnd = maxHeight + + for _, option := range p.DefaultOptions { + p.selectOption(option) + } + + area, err := DefaultArea.Start(p.renderSelectMenu()) + defer area.Stop() + if err != nil { + return nil, fmt.Errorf("could not start area: %w", err) + } + + area.Update(p.renderSelectMenu()) + + cursor.Hide() + defer cursor.Show() + err = keyboard.Listen(func(keyInfo keys.Key) (stop bool, err error) { + key := keyInfo.Code + + if p.MaxHeight > len(p.fuzzySearchMatches) { + maxHeight = len(p.fuzzySearchMatches) + } else { + maxHeight = p.MaxHeight + } + + switch key { + case keys.RuneKey: + // Fuzzy search for options + // append to fuzzy search string + p.fuzzySearchString += keyInfo.String() + p.selectedOption = 0 + p.displayedOptionsStart = 0 + p.displayedOptionsEnd = maxHeight + p.displayedOptions = append([]string{}, p.fuzzySearchMatches[:maxHeight]...) + area.Update(p.renderSelectMenu()) + case keys.Tab: + if len(p.fuzzySearchMatches) == 0 { + return false, nil + } + area.Update(p.renderFinishedMenu()) + return true, nil + case keys.Space: + p.fuzzySearchString += " " + p.selectedOption = 0 + area.Update(p.renderSelectMenu()) + case keys.Backspace: + // Remove last character from fuzzy search string + if len(p.fuzzySearchString) > 0 { + // Handle UTF-8 characters + p.fuzzySearchString = string([]rune(p.fuzzySearchString)[:len([]rune(p.fuzzySearchString))-1]) + } + + if p.fuzzySearchString == "" { + p.fuzzySearchMatches = append([]string{}, p.Options...) + } + + p.renderSelectMenu() + + if len(p.fuzzySearchMatches) > p.MaxHeight { + maxHeight = p.MaxHeight + } else { + maxHeight = len(p.fuzzySearchMatches) + } + + p.selectedOption = 0 + p.displayedOptionsStart = 0 + p.displayedOptionsEnd = maxHeight + p.displayedOptions = append([]string{}, p.fuzzySearchMatches[p.displayedOptionsStart:p.displayedOptionsEnd]...) + + area.Update(p.renderSelectMenu()) + case keys.Up: + if len(p.fuzzySearchMatches) == 0 { + return false, nil + } + if p.selectedOption > 0 { + p.selectedOption-- + if p.selectedOption < p.displayedOptionsStart { + p.displayedOptionsStart-- + p.displayedOptionsEnd-- + if p.displayedOptionsStart < 0 { + p.displayedOptionsStart = 0 + p.displayedOptionsEnd = maxHeight + } + p.displayedOptions = append([]string{}, p.fuzzySearchMatches[p.displayedOptionsStart:p.displayedOptionsEnd]...) + } + } else { + p.selectedOption = len(p.fuzzySearchMatches) - 1 + p.displayedOptionsStart = len(p.fuzzySearchMatches) - maxHeight + p.displayedOptionsEnd = len(p.fuzzySearchMatches) + p.displayedOptions = append([]string{}, p.fuzzySearchMatches[p.displayedOptionsStart:p.displayedOptionsEnd]...) + } + + area.Update(p.renderSelectMenu()) + case keys.Down: + if len(p.fuzzySearchMatches) == 0 { + return false, nil + } + p.displayedOptions = p.fuzzySearchMatches[:maxHeight] + if p.selectedOption < len(p.fuzzySearchMatches)-1 { + p.selectedOption++ + if p.selectedOption >= p.displayedOptionsEnd { + p.displayedOptionsStart++ + p.displayedOptionsEnd++ + p.displayedOptions = append([]string{}, p.fuzzySearchMatches[p.displayedOptionsStart:p.displayedOptionsEnd]...) + } + } else { + p.selectedOption = 0 + p.displayedOptionsStart = 0 + p.displayedOptionsEnd = maxHeight + p.displayedOptions = append([]string{}, p.fuzzySearchMatches[p.displayedOptionsStart:p.displayedOptionsEnd]...) + } + + area.Update(p.renderSelectMenu()) + case keys.CtrlC: + os.Exit(1) + case keys.Enter: + // Select option if not already selected + p.selectOption(p.fuzzySearchMatches[p.selectedOption]) + area.Update(p.renderSelectMenu()) + } + + return false, nil + }) + if err != nil { + fmt.Println(err) + return nil, fmt.Errorf("failed to start keyboard listener: %w", err) + } + + var result []string + for _, selectedOption := range p.selectedOptions { + result = append(result, p.Options[selectedOption]) + } + + return result, nil +} + +func (p InteractiveMultiselectPrinter) findOptionByText(text string) int { + for i, option := range p.Options { + if option == text { + return i + } + } + return -1 +} + +func (p *InteractiveMultiselectPrinter) isSelected(optionText string) bool { + for _, selectedOption := range p.selectedOptions { + if p.Options[selectedOption] == optionText { + return true + } + } + + return false +} + +func (p *InteractiveMultiselectPrinter) selectOption(optionText string) { + if p.isSelected(optionText) { + // Remove from selected options + for i, selectedOption := range p.selectedOptions { + if p.Options[selectedOption] == optionText { + p.selectedOptions = append(p.selectedOptions[:i], p.selectedOptions[i+1:]...) + break + } + } + } else { + // Add to selected options + p.selectedOptions = append(p.selectedOptions, p.findOptionByText(optionText)) + } +} + +func (p *InteractiveMultiselectPrinter) renderSelectMenu() string { + var content string + content += Sprintf("%s %s: %s\n", p.text, ThemeDefault.SecondaryStyle.Sprint("[select with enter, type to search, confirm with tab]"), p.fuzzySearchString) + + // find options that match fuzzy search string + rankedResults := fuzzy.RankFindFold(p.fuzzySearchString, p.Options) + // map rankedResults to fuzzySearchMatches + p.fuzzySearchMatches = []string{} + if len(rankedResults) != len(p.Options) { + sort.Sort(rankedResults) + } + for _, result := range rankedResults { + p.fuzzySearchMatches = append(p.fuzzySearchMatches, result.Target) + } + + indexMapper := make([]string, len(p.fuzzySearchMatches)) + for i := 0; i < len(p.fuzzySearchMatches); i++ { + // if in displayed options range + if i >= p.displayedOptionsStart && i < p.displayedOptionsEnd { + indexMapper[i] = p.fuzzySearchMatches[i] + } + } + + for i, option := range indexMapper { + if option == "" { + continue + } + var checkmark string + if p.isSelected(option) { + checkmark = fmt.Sprintf("[%s]", Green("✓")) + } else { + checkmark = fmt.Sprintf("[%s]", Red("✗")) + } + if i == p.selectedOption { + content += Sprintf("%s %s %s\n", p.renderSelector(), checkmark, option) + } else { + content += Sprintf(" %s %s\n", checkmark, option) + } + } + + return content +} + +func (p InteractiveMultiselectPrinter) renderFinishedMenu() string { + var content string + content += Sprintf("%s: %s\n", p.text, p.fuzzySearchString) + for _, option := range p.selectedOptions { + content += Sprintf(" %s %s\n", p.renderSelector(), p.Options[option]) + } + + return content +} + +func (p InteractiveMultiselectPrinter) renderSelector() string { + return p.SelectorStyle.Sprint(p.Selector) +} diff --git a/interactive_select_printer.go b/interactive_select_printer.go new file mode 100644 index 000000000..c9fe5ded6 --- /dev/null +++ b/interactive_select_printer.go @@ -0,0 +1,289 @@ +package pterm + +import ( + "fmt" + "os" + "sort" + + "atomicgo.dev/cursor" + "atomicgo.dev/keyboard" + "atomicgo.dev/keyboard/keys" + "github.com/lithammer/fuzzysearch/fuzzy" +) + +var ( + // DefaultInteractiveSelect is the default InteractiveSelect printer. + DefaultInteractiveSelect = InteractiveSelectPrinter{ + TextStyle: &ThemeDefault.PrimaryStyle, + DefaultText: "Please select an option", + Options: []string{}, + OptionStyle: &ThemeDefault.DefaultText, + DefaultOption: "", + MaxHeight: 5, + Selector: ">", + SelectorStyle: &ThemeDefault.SecondaryStyle, + } +) + +// InteractiveSelectPrinter is a printer for interactive select menus. +type InteractiveSelectPrinter struct { + TextStyle *Style + DefaultText string + Options []string + OptionStyle *Style + DefaultOption string + MaxHeight int + Selector string + SelectorStyle *Style + + selectedOption int + result string + text string + fuzzySearchString string + fuzzySearchMatches []string + displayedOptions []string + displayedOptionsStart int + displayedOptionsEnd int +} + +// WithDefaultText sets the default text. +func (p InteractiveSelectPrinter) WithDefaultText(text string) *InteractiveSelectPrinter { + p.DefaultText = text + return &p +} + +// WithOptions sets the options. +func (p InteractiveSelectPrinter) WithOptions(options []string) *InteractiveSelectPrinter { + p.Options = options + return &p +} + +// WithDefaultOption sets the default options. +func (p InteractiveSelectPrinter) WithDefaultOption(option string) *InteractiveSelectPrinter { + p.DefaultOption = option + return &p +} + +// WithMaxHeight sets the maximum height of the select menu. +func (p InteractiveSelectPrinter) WithMaxHeight(maxHeight int) *InteractiveSelectPrinter { + p.MaxHeight = maxHeight + return &p +} + +// Show shows the interactive select menu and returns the selected entry. +func (p *InteractiveSelectPrinter) Show(text ...string) (string, error) { + if len(text) == 0 || Sprint(text[0]) == "" { + text = []string{p.DefaultText} + } + + p.text = p.TextStyle.Sprint(text[0]) + p.fuzzySearchMatches = append([]string{}, p.Options...) + + if p.MaxHeight == 0 { + p.MaxHeight = DefaultInteractiveSelect.MaxHeight + } + + maxHeight := p.MaxHeight + if maxHeight > len(p.fuzzySearchMatches) { + maxHeight = len(p.fuzzySearchMatches) + } + + if len(p.Options) == 0 { + return "", fmt.Errorf("no options provided") + } + + p.displayedOptions = append([]string{}, p.fuzzySearchMatches[:maxHeight]...) + p.displayedOptionsStart = 0 + p.displayedOptionsEnd = maxHeight + + // Get index of default option + if p.DefaultOption != "" { + for i, option := range p.Options { + if option == p.DefaultOption { + p.selectedOption = i + if i > 0 { + p.displayedOptionsStart = i - 1 + p.displayedOptionsEnd = i - 1 + maxHeight + } else { + p.displayedOptionsStart = 0 + p.displayedOptionsEnd = maxHeight + } + p.displayedOptions = p.Options[p.displayedOptionsStart:p.displayedOptionsEnd] + break + } + } + } + + area, err := DefaultArea.Start(p.renderSelectMenu()) + defer area.Stop() + if err != nil { + return "", fmt.Errorf("could not start area: %w", err) + } + + area.Update(p.renderSelectMenu()) + + cursor.Hide() + defer cursor.Show() + err = keyboard.Listen(func(keyInfo keys.Key) (stop bool, err error) { + key := keyInfo.Code + + if p.MaxHeight > len(p.fuzzySearchMatches) { + maxHeight = len(p.fuzzySearchMatches) + } else { + maxHeight = p.MaxHeight + } + + switch key { + case keys.RuneKey: + // Fuzzy search for options + // append to fuzzy search string + p.fuzzySearchString += keyInfo.String() + p.selectedOption = 0 + p.displayedOptionsStart = 0 + p.displayedOptionsEnd = maxHeight + p.displayedOptions = append([]string{}, p.fuzzySearchMatches[:maxHeight]...) + area.Update(p.renderSelectMenu()) + case keys.Space: + p.fuzzySearchString += " " + p.selectedOption = 0 + area.Update(p.renderSelectMenu()) + case keys.Backspace: + // Remove last character from fuzzy search string + if len(p.fuzzySearchString) > 0 { + // Handle UTF-8 characters + p.fuzzySearchString = string([]rune(p.fuzzySearchString)[:len([]rune(p.fuzzySearchString))-1]) + } + + if p.fuzzySearchString == "" { + p.fuzzySearchMatches = append([]string{}, p.Options...) + } + + p.renderSelectMenu() + + if len(p.fuzzySearchMatches) > p.MaxHeight { + maxHeight = p.MaxHeight + } else { + maxHeight = len(p.fuzzySearchMatches) + } + + p.selectedOption = 0 + p.displayedOptionsStart = 0 + p.displayedOptionsEnd = maxHeight + p.displayedOptions = append([]string{}, p.fuzzySearchMatches[p.displayedOptionsStart:p.displayedOptionsEnd]...) + + area.Update(p.renderSelectMenu()) + case keys.Up: + if len(p.fuzzySearchMatches) == 0 { + return false, nil + } + if p.selectedOption > 0 { + p.selectedOption-- + if p.selectedOption < p.displayedOptionsStart { + p.displayedOptionsStart-- + p.displayedOptionsEnd-- + if p.displayedOptionsStart < 0 { + p.displayedOptionsStart = 0 + p.displayedOptionsEnd = maxHeight + } + p.displayedOptions = append([]string{}, p.fuzzySearchMatches[p.displayedOptionsStart:p.displayedOptionsEnd]...) + } + } else { + p.selectedOption = len(p.fuzzySearchMatches) - 1 + p.displayedOptionsStart = len(p.fuzzySearchMatches) - maxHeight + p.displayedOptionsEnd = len(p.fuzzySearchMatches) + p.displayedOptions = append([]string{}, p.fuzzySearchMatches[p.displayedOptionsStart:p.displayedOptionsEnd]...) + } + + area.Update(p.renderSelectMenu()) + case keys.Down: + if len(p.fuzzySearchMatches) == 0 { + return false, nil + } + p.displayedOptions = p.fuzzySearchMatches[:maxHeight] + if p.selectedOption < len(p.fuzzySearchMatches)-1 { + p.selectedOption++ + if p.selectedOption >= p.displayedOptionsEnd { + p.displayedOptionsStart++ + p.displayedOptionsEnd++ + p.displayedOptions = append([]string{}, p.fuzzySearchMatches[p.displayedOptionsStart:p.displayedOptionsEnd]...) + } + } else { + p.selectedOption = 0 + p.displayedOptionsStart = 0 + p.displayedOptionsEnd = maxHeight + p.displayedOptions = append([]string{}, p.fuzzySearchMatches[p.displayedOptionsStart:p.displayedOptionsEnd]...) + } + + area.Update(p.renderSelectMenu()) + case keys.CtrlC: + os.Exit(1) + case keys.Enter: + if len(p.fuzzySearchMatches) == 0 { + return false, nil + } + area.Update(p.renderFinishedMenu()) + return true, nil + } + + return false, nil + }) + if err != nil { + fmt.Println(err) + return "", fmt.Errorf("failed to start keyboard listener: %w", err) + } + + return p.result, nil +} + +func (p *InteractiveSelectPrinter) renderSelectMenu() string { + var content string + content += Sprintf("%s %s: %s\n", p.text, ThemeDefault.SecondaryStyle.Sprint("[type to search]"), p.fuzzySearchString) + + // find options that match fuzzy search string + rankedResults := fuzzy.RankFindFold(p.fuzzySearchString, p.Options) + // map rankedResults to fuzzySearchMatches + p.fuzzySearchMatches = []string{} + if len(rankedResults) != len(p.Options) { + sort.Sort(rankedResults) + } + for _, result := range rankedResults { + p.fuzzySearchMatches = append(p.fuzzySearchMatches, result.Target) + } + + if len(p.fuzzySearchMatches) != 0 { + p.result = p.fuzzySearchMatches[p.selectedOption] + } + + indexMapper := make([]string, len(p.fuzzySearchMatches)) + for i := 0; i < len(p.fuzzySearchMatches); i++ { + // if in displayed options range + if i >= p.displayedOptionsStart && i < p.displayedOptionsEnd { + indexMapper[i] = p.fuzzySearchMatches[i] + } + } + + for i, option := range indexMapper { + if option == "" { + continue + } + if i == p.selectedOption { + content += Sprintf("%s %s\n", p.renderSelector(), option) + } else { + content += Sprintf(" %s\n", option) + } + } + + return content +} + +func (p InteractiveSelectPrinter) renderFinishedMenu() string { + var content string + content += Sprintf("%s: %s\n", p.text, p.fuzzySearchString) + content += Sprintf(" %s %s\n", p.renderSelector(), p.result) + + return content +} + +func (p InteractiveSelectPrinter) renderSelector() string { + return p.SelectorStyle.Sprint(p.Selector) +} diff --git a/interactive_select_printer_test.go b/interactive_select_printer_test.go new file mode 100644 index 000000000..35026f8e3 --- /dev/null +++ b/interactive_select_printer_test.go @@ -0,0 +1,41 @@ +package pterm_test + +import ( + "testing" + + "atomicgo.dev/keyboard" + "atomicgo.dev/keyboard/keys" + "github.com/MarvinJWendt/testza" + + "github.com/pterm/pterm" +) + +func TestInteractiveSelectPrinter_Show(t *testing.T) { + go func() { + keyboard.SimulateKeyPress(keys.Down) + keyboard.SimulateKeyPress(keys.Down) + keyboard.SimulateKeyPress(keys.Enter) + }() + result, _ := pterm.DefaultInteractiveSelect.WithOptions([]string{"a", "b", "c", "d", "e"}).WithDefaultOption("b").Show() + testza.AssertEqual(t, "d", result) +} + +func TestInteractiveSelectPrinter_WithDefaultText(t *testing.T) { + p := pterm.DefaultInteractiveSelect.WithDefaultText("default") + testza.AssertEqual(t, p.DefaultText, "default") +} + +func TestInteractiveSelectPrinter_WithDefaultOption(t *testing.T) { + p := pterm.DefaultInteractiveSelect.WithDefaultOption("default") + testza.AssertEqual(t, p.DefaultOption, "default") +} + +func TestInteractiveSelectPrinter_WithOptions(t *testing.T) { + p := pterm.DefaultInteractiveSelect.WithOptions([]string{"a", "b", "c"}) + testza.AssertEqual(t, p.Options, []string{"a", "b", "c"}) +} + +func TestInteractiveSelectPrinter_WithMaxHeight(t *testing.T) { + p := pterm.DefaultInteractiveSelect.WithMaxHeight(1337) + testza.AssertEqual(t, p.MaxHeight, 1337) +} diff --git a/interactive_textinput_printer.go b/interactive_textinput_printer.go new file mode 100644 index 000000000..f6c682405 --- /dev/null +++ b/interactive_textinput_printer.go @@ -0,0 +1,216 @@ +package pterm + +import ( + "os" + "strings" + + "atomicgo.dev/cursor" + "atomicgo.dev/keyboard" + "atomicgo.dev/keyboard/keys" + + "github.com/pterm/pterm/internal" +) + +var ( + // DefaultInteractiveTextInput is the default InteractiveTextInput printer. + DefaultInteractiveTextInput = InteractiveTextInputPrinter{ + DefaultText: "Input text", + TextStyle: &ThemeDefault.PrimaryStyle, + } +) + +// InteractiveTextInputPrinter is a printer for interactive select menus. +type InteractiveTextInputPrinter struct { + TextStyle *Style + DefaultText string + MultiLine bool + + input []string + cursorXPos int + cursorYPos int + text string +} + +// WithDefaultText sets the default text. +func (p *InteractiveTextInputPrinter) WithDefaultText(text string) *InteractiveTextInputPrinter { + p.DefaultText = text + return p +} + +// WithTextStyle sets the text style. +func (p *InteractiveTextInputPrinter) WithTextStyle(style *Style) *InteractiveTextInputPrinter { + p.TextStyle = style + return p +} + +// WithMultiLine sets the multi line flag. +func (p *InteractiveTextInputPrinter) WithMultiLine(multiLine ...bool) *InteractiveTextInputPrinter { + p.MultiLine = internal.WithBoolean(multiLine) + return p +} + +// Show shows the interactive select menu and returns the selected entry. +func (p *InteractiveTextInputPrinter) Show(text ...string) (string, error) { + var areaText string + + if len(text) == 0 || Sprint(text[0]) == "" { + text = []string{p.DefaultText} + } + + if p.MultiLine { + areaText = p.TextStyle.Sprintfln("%s %s :", text[0], ThemeDefault.SecondaryStyle.Sprint("[Press tab to submit]")) + } else { + areaText = p.TextStyle.Sprintf("%s: ", text[0]) + } + p.text = areaText + area, err := DefaultArea.Start(areaText) + defer area.Stop() + if err != nil { + return "", err + } + + cursor.Up(1) + cursor.StartOfLine() + if !p.MultiLine { + cursor.Right(len(RemoveColorFromString(areaText))) + } + + err = keyboard.Listen(func(key keys.Key) (stop bool, err error) { + if !p.MultiLine { + p.cursorYPos = 0 + } + if len(p.input) == 0 { + p.input = append(p.input, "") + } + + switch key.Code { + case keys.Tab: + if p.MultiLine { + return true, nil + } + case keys.Enter: + if p.MultiLine { + if key.AltPressed { + p.cursorXPos = 0 + } + appendAfterY := append([]string{}, p.input[p.cursorYPos+1:]...) + appendAfterX := string(append([]rune{}, []rune(p.input[p.cursorYPos])[len([]rune(p.input[p.cursorYPos]))+p.cursorXPos:]...)) + p.input[p.cursorYPos] = string(append([]rune{}, []rune(p.input[p.cursorYPos])[:len([]rune(p.input[p.cursorYPos]))+p.cursorXPos]...)) + p.input = append(p.input[:p.cursorYPos+1], appendAfterX) + p.input = append(p.input, appendAfterY...) + p.cursorYPos++ + p.cursorXPos = -internal.GetStringMaxWidth(p.input[p.cursorYPos]) + cursor.Down(1) + cursor.StartOfLine() + } else { + return true, nil + } + case keys.RuneKey: + p.input[p.cursorYPos] = string(append([]rune(p.input[p.cursorYPos])[:len([]rune(p.input[p.cursorYPos]))+p.cursorXPos], append([]rune(key.String()), []rune(p.input[p.cursorYPos])[len([]rune(p.input[p.cursorYPos]))+p.cursorXPos:]...)...)) + case keys.Space: + p.input[p.cursorYPos] = string(append([]rune(p.input[p.cursorYPos])[:len([]rune(p.input[p.cursorYPos]))+p.cursorXPos], append([]rune(" "), []rune(p.input[p.cursorYPos])[len([]rune(p.input[p.cursorYPos]))+p.cursorXPos:]...)...)) + case keys.Backspace: + if len([]rune(p.input[p.cursorYPos]))+p.cursorXPos > 0 { + p.input[p.cursorYPos] = string(append([]rune(p.input[p.cursorYPos])[:len([]rune(p.input[p.cursorYPos]))-1+p.cursorXPos], []rune(p.input[p.cursorYPos])[len([]rune(p.input[p.cursorYPos]))+p.cursorXPos:]...)) + } else if p.cursorYPos > 0 { + p.input[p.cursorYPos-1] += p.input[p.cursorYPos] + appendAfterY := append([]string{}, p.input[p.cursorYPos+1:]...) + p.input = append(p.input[:p.cursorYPos], appendAfterY...) + p.cursorXPos = 0 + p.cursorYPos-- + } + case keys.Delete: + if len([]rune(p.input[p.cursorYPos]))+p.cursorXPos < len([]rune(p.input[p.cursorYPos])) { + p.input[p.cursorYPos] = string(append([]rune(p.input[p.cursorYPos])[:len([]rune(p.input[p.cursorYPos]))+p.cursorXPos], []rune(p.input[p.cursorYPos])[len([]rune(p.input[p.cursorYPos]))+p.cursorXPos+1:]...)) + p.cursorXPos++ + } else if p.cursorYPos < len(p.input)-1 { + p.input[p.cursorYPos] += p.input[p.cursorYPos+1] + appendAfterY := append([]string{}, p.input[p.cursorYPos+2:]...) + p.input = append(p.input[:p.cursorYPos+1], appendAfterY...) + p.cursorXPos = 0 + } + case keys.CtrlC: + os.Exit(0) + case keys.Down: + if p.cursorYPos+1 < len(p.input) { + p.cursorXPos = (internal.GetStringMaxWidth(p.input[p.cursorYPos]) + p.cursorXPos) - internal.GetStringMaxWidth(p.input[p.cursorYPos+1]) + if p.cursorXPos > 0 { + p.cursorXPos = 0 + } + p.cursorYPos++ + } + case keys.Up: + if p.cursorYPos > 0 { + p.cursorXPos = (internal.GetStringMaxWidth(p.input[p.cursorYPos]) + p.cursorXPos) - internal.GetStringMaxWidth(p.input[p.cursorYPos-1]) + if p.cursorXPos > 0 { + p.cursorXPos = 0 + } + p.cursorYPos-- + } + } + + if internal.GetStringMaxWidth(p.input[p.cursorYPos]) > 0 { + switch key.Code { + case keys.Right: + if p.cursorXPos < 0 { + p.cursorXPos++ + } else if p.cursorYPos < len(p.input)-1 { + p.cursorYPos++ + p.cursorXPos = -internal.GetStringMaxWidth(p.input[p.cursorYPos]) + } + case keys.Left: + if p.cursorXPos+internal.GetStringMaxWidth(p.input[p.cursorYPos]) > 0 { + p.cursorXPos-- + } else if p.cursorYPos > 0 { + p.cursorYPos-- + p.cursorXPos = 0 + } + } + } + + p.updateArea(area) + + return false, nil + }) + if err != nil { + return "", err + } + + for i, s := range p.input { + if i < len(p.input)-1 { + areaText += s + "\n" + } else { + areaText += s + } + } + + return strings.ReplaceAll(areaText, p.text, ""), nil +} + +func (p *InteractiveTextInputPrinter) updateArea(area *AreaPrinter) string { + if !p.MultiLine { + p.cursorYPos = 0 + } + areaText := p.text + for i, s := range p.input { + if i < len(p.input)-1 { + areaText += s + "\n" + } else { + areaText += s + } + } + if p.cursorXPos+internal.GetStringMaxWidth(p.input[p.cursorYPos]) < 1 { + p.cursorXPos = -internal.GetStringMaxWidth(p.input[p.cursorYPos]) + } + + cursor.StartOfLine() + area.Update(areaText) + cursor.Up(len(p.input) - p.cursorYPos) + cursor.StartOfLine() + if p.MultiLine { + cursor.Right(internal.GetStringMaxWidth(p.input[p.cursorYPos]) + p.cursorXPos) + } else { + cursor.Right(internal.GetStringMaxWidth(areaText) + p.cursorXPos) + } + return areaText +} diff --git a/interactive_textinput_printer_test.go b/interactive_textinput_printer_test.go new file mode 100644 index 000000000..1b0d9523d --- /dev/null +++ b/interactive_textinput_printer_test.go @@ -0,0 +1,30 @@ +package pterm_test + +import ( + "testing" + + "github.com/MarvinJWendt/testza" + + "github.com/pterm/pterm" +) + +func TestInteractiveTextInputPrinter_WithDefaultText(t *testing.T) { + p := pterm.DefaultInteractiveTextInput.WithDefaultText("default") + testza.AssertEqual(t, p.DefaultText, "default") +} + +func TestInteractiveTextInputPrinter_WithMultiLine_true(t *testing.T) { + p := pterm.DefaultInteractiveTextInput.WithMultiLine() + testza.AssertTrue(t, p.MultiLine) +} + +func TestInteractiveTextInputPrinter_WithMultiLine_false(t *testing.T) { + p := pterm.DefaultInteractiveTextInput.WithMultiLine(false) + testza.AssertFalse(t, p.MultiLine) +} + +func TestInteractiveTextInputPrinter_WithTextStyle(t *testing.T) { + style := pterm.NewStyle(pterm.FgRed) + p := pterm.DefaultInteractiveTextInput.WithTextStyle(style) + testza.AssertEqual(t, p.TextStyle, style) +} diff --git a/theme.go b/theme.go index 9418642cf..b02ac3e28 100644 --- a/theme.go +++ b/theme.go @@ -4,7 +4,8 @@ var ( // ThemeDefault is the default theme used by PTerm. // If this variable is overwritten, the new value is used as default theme. ThemeDefault = Theme{ - PrimaryStyle: Style{FgCyan}, + DefaultText: Style{FgDefault, BgDefault}, + PrimaryStyle: Style{FgLightCyan}, SecondaryStyle: Style{FgLightMagenta}, HighlightStyle: Style{Bold, FgYellow}, InfoMessageStyle: Style{FgLightCyan}, @@ -49,6 +50,7 @@ var ( // Theme contains every Style used in PTerm. You can create own themes for your application or use one // of the existing themes. type Theme struct { + DefaultText Style PrimaryStyle Style SecondaryStyle Style HighlightStyle Style