Skip to content
This repository has been archived by the owner on Apr 19, 2024. It is now read-only.

Commit

Permalink
[#101] fix incorrect line count on narrow terminals (Builds on #288) (#…
Browse files Browse the repository at this point in the history
…291)

* [#101] fix incorrect line count on narrow terminals
also add github actions workflow

* Fixed how the renderer erases the previously printed lines on narrow terminals. core/template.go now generates two templates: one with color escape codes, and one without. The renderer determines how many lines to backtrack using the template without color codes. Added proper rune counting for unicode.

* Don't reuse prompt in tests/ask.go since the renderer's lineCount cannot be reset

* Handle changing window sizes between prompt calls

* Handle user input which word wraps in input, multiline, and password

Co-authored-by: Cory Bennett <[email protected]>
Co-authored-by: Logan L <[email protected]>
  • Loading branch information
3 people authored Jul 8, 2020
1 parent 2fcbaa3 commit 0257161
Show file tree
Hide file tree
Showing 49 changed files with 16,547 additions and 55 deletions.
35 changes: 35 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
name: Test

on:
push:
branches: [ master ]
pull_request:
branches: [ master ]

env:
GO111MODULE: on

jobs:
test:
strategy:
matrix:
platform: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.platform }}
steps:
- name: Set up Go 1.13
uses: actions/setup-go@v2
with:
go-version: ^1.13
id: go

- name: Check out code into the Go module directory
uses: actions/checkout@v2

- name: Installer runner
run: go get -v github.com/alecaivazis/run
env:
GO111MODULE: off

- name: Run Tests
run: run tests

67 changes: 49 additions & 18 deletions core/template.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,50 +11,81 @@ import (
// DisableColor can be used to make testing reliable
var DisableColor = false

var TemplateFuncs = map[string]interface{}{
var TemplateFuncsWithColor = map[string]interface{}{

This comment has been minimized.

Copy link
@efekarakus

efekarakus Jul 13, 2020

Hi y'all! thanks for all the hard work 🥳, I just wanted to point out that this change broke our code since we were overriding core.TemplateFuncs.
It's np we can fix it on our end, but for the future I'd appreciate if we watch out so that changes to the pkg's public interface is additive only.

// Templates with Color formatting. See Documentation: https://github.com/mgutz/ansi#style-format
"color": ansi.ColorCode,
}

var TemplateFuncsNoColor = map[string]interface{}{
// Templates without Color formatting. For layout/ testing.
"color": func(color string) string {
if DisableColor {
return ""
}
return ansi.ColorCode(color)
return ""
},
}

func RunTemplate(tmpl string, data interface{}) (string, error) {
t, err := getTemplate(tmpl)
//RunTemplate returns two formatted strings given a template and
//the data it requires. The first string returned is generated for
//user-facing output and may or may not contain ANSI escape codes
//for colored output. The second string does not contain escape codes
//and can be used by the renderer for layout purposes.
func RunTemplate(tmpl string, data interface{}) (string, string, error) {
tPair, err := getTemplatePair(tmpl)
if err != nil {
return "", "", err
}
userBuf := bytes.NewBufferString("")
err = tPair[0].Execute(userBuf, data)
if err != nil {
return "", err
return "", "", err
}
buf := bytes.NewBufferString("")
err = t.Execute(buf, data)
layoutBuf := bytes.NewBufferString("")
err = tPair[1].Execute(layoutBuf, data)
if err != nil {
return "", err
return userBuf.String(), "", err
}
return buf.String(), err
return userBuf.String(), layoutBuf.String(), err
}

var (
memoizedGetTemplate = map[string]*template.Template{}
memoizedGetTemplate = map[string][2]*template.Template{}

memoMutex = &sync.RWMutex{}
)

func getTemplate(tmpl string) (*template.Template, error) {
//getTemplatePair returns a pair of compiled templates where the
//first template is generated for user-facing output and the
//second is generated for use by the renderer. The second
//template does not contain any color escape codes, whereas
//the first template may or may not depending on DisableColor.
func getTemplatePair(tmpl string) ([2]*template.Template, error) {
memoMutex.RLock()
if t, ok := memoizedGetTemplate[tmpl]; ok {
memoMutex.RUnlock()
return t, nil
}
memoMutex.RUnlock()

t, err := template.New("prompt").Funcs(TemplateFuncs).Parse(tmpl)
templatePair := [2]*template.Template{nil, nil}

templateNoColor, err := template.New("prompt").Funcs(TemplateFuncsNoColor).Parse(tmpl)
if err != nil {
return nil, err
return [2]*template.Template{}, err
}

templatePair[1] = templateNoColor

if DisableColor {
templatePair[0] = templatePair[1]
} else {
templateWithColor, err := template.New("prompt").Funcs(TemplateFuncsWithColor).Parse(tmpl)
templatePair[0] = templateWithColor
if err != nil {
return [2]*template.Template{}, err
}
}

memoMutex.Lock()
memoizedGetTemplate[tmpl] = t
memoizedGetTemplate[tmpl] = templatePair
memoMutex.Unlock()
return t, nil
return templatePair, nil
}
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ require (
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/stretchr/testify v1.2.1
golang.org/x/crypto v0.0.0-20190530122614-20be4c3c3ed5 // indirect
golang.org/x/crypto v0.0.0-20190530122614-20be4c3c3ed5
golang.org/x/sys v0.0.0-20190530182044-ad28b68e88f1 // indirect
golang.org/x/text v0.3.0
)
Expand Down
6 changes: 5 additions & 1 deletion input.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,8 +89,12 @@ func (i *Input) Prompt(config *PromptConfig) (interface{}, error) {
return i.Default, err
}

lineStr := string(line)

i.AppendRenderedText(lineStr)

// we're done
return string(line), err
return lineStr, err
}

func (i *Input) Cleanup(config *PromptConfig, val interface{}) error {
Expand Down
2 changes: 1 addition & 1 deletion multiline.go
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ func (i *Multiline) Prompt(config *PromptConfig) (interface{}, error) {
return i.Default, err
}

// we're done
i.AppendRenderedText(val)
return val, err
}

Expand Down
17 changes: 12 additions & 5 deletions password.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package survey

import (
"fmt"
"strings"

"github.com/AlecAivazis/survey/v2/core"
"github.com/AlecAivazis/survey/v2/terminal"
Expand Down Expand Up @@ -34,16 +35,16 @@ var PasswordQuestionTemplate = `
{{- color "default+hb"}}{{ .Message }} {{color "reset"}}
{{- if and .Help (not .ShowHelp)}}{{color "cyan"}}[{{ .Config.HelpInput }} for help]{{color "reset"}} {{end}}`

func (p *Password) Prompt(config *PromptConfig) (line interface{}, err error) {
func (p *Password) Prompt(config *PromptConfig) (interface{}, error) {
// render the question template
out, err := core.RunTemplate(
userOut, _, err := core.RunTemplate(
PasswordQuestionTemplate,
PasswordTemplateData{
Password: *p,
Config: config,
},
)
fmt.Fprint(terminal.NewAnsiStdout(p.Stdio().Out), out)
fmt.Fprint(terminal.NewAnsiStdout(p.Stdio().Out), userOut)
if err != nil {
return "", err
}
Expand All @@ -60,9 +61,10 @@ func (p *Password) Prompt(config *PromptConfig) (line interface{}, err error) {

cursor := p.NewCursor()

line := []rune{}
// process answers looking for help prompt answer
for {
line, err := rr.ReadLine('*')
line, err = rr.ReadLine('*')
if err != nil {
return string(line), err
}
Expand All @@ -84,8 +86,13 @@ func (p *Password) Prompt(config *PromptConfig) (line interface{}, err error) {
}
continue
}
return string(line), err

break
}

lineStr := string(line)
p.AppendRenderedText(strings.Repeat("*", len(lineStr)))
return lineStr, err
}

// Cleanup hides the string with a fixed number of characters.
Expand Down
2 changes: 1 addition & 1 deletion password_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ func TestPasswordRender(t *testing.T) {
// set the icon set
test.data.Config = defaultPromptConfig()

actual, err := core.RunTemplate(
actual, _, err := core.RunTemplate(
PasswordQuestionTemplate,
&test.data,
)
Expand Down
113 changes: 88 additions & 25 deletions renderer.go
Original file line number Diff line number Diff line change
@@ -1,17 +1,19 @@
package survey

import (
"bytes"
"fmt"
"strings"
"unicode/utf8"

"github.com/AlecAivazis/survey/v2/core"
"github.com/AlecAivazis/survey/v2/terminal"
goterm "golang.org/x/crypto/ssh/terminal"
)

type Renderer struct {
stdio terminal.Stdio
lineCount int
errorLineCount int
renderedErrors bytes.Buffer
renderedText bytes.Buffer
}

type ErrorTemplateData struct {
Expand Down Expand Up @@ -42,27 +44,67 @@ func (r *Renderer) NewCursor() *terminal.Cursor {
}

func (r *Renderer) Error(config *PromptConfig, invalid error) error {
// since errors are printed on top we need to reset the prompt
// as well as any previous error print
r.resetPrompt(r.lineCount + r.errorLineCount)
// cleanup the currently rendered errors
r.resetPrompt(r.countLines(r.renderedErrors))
r.renderedErrors.Reset()

// we just cleared the prompt lines
r.lineCount = 0
out, err := core.RunTemplate(ErrorTemplate, &ErrorTemplateData{
// cleanup the rest of the prompt
r.resetPrompt(r.countLines(r.renderedText))
r.renderedText.Reset()

userOut, layoutOut, err := core.RunTemplate(ErrorTemplate, &ErrorTemplateData{
Error: invalid,
Icon: config.Icons.Error,
})
if err != nil {
return err
}
// keep track of how many lines are printed so we can clean up later
r.errorLineCount = strings.Count(out, "\n")

// send the message to the user
fmt.Fprint(terminal.NewAnsiStdout(r.stdio.Out), out)
fmt.Fprint(terminal.NewAnsiStdout(r.stdio.Out), userOut)

// add the printed text to the rendered error buffer so we can cleanup later
r.appendRenderedError(layoutOut)

return nil
}

func (r *Renderer) Render(tmpl string, data interface{}) error {
// cleanup the currently rendered text
lineCount := r.countLines(r.renderedText)
r.resetPrompt(lineCount)
r.renderedText.Reset()

// render the template summarizing the current state
userOut, layoutOut, err := core.RunTemplate(tmpl, data)
if err != nil {
return err
}

// print the summary
fmt.Fprint(terminal.NewAnsiStdout(r.stdio.Out), userOut)

// add the printed text to the rendered text buffer so we can cleanup later
r.AppendRenderedText(layoutOut)

// nothing went wrong
return nil
}

// appendRenderedError appends text to the renderer's error buffer
// which is used to track what has been printed. It is not exported
// as errors should only be displayed via Error(config, error).
func (r *Renderer) appendRenderedError(text string) {
r.renderedErrors.WriteString(text)
}

// AppendRenderedText appends text to the renderer's text buffer
// which is used to track of what has been printed. The buffer is used
// to calculate how many lines to erase before updating the prompt.
func (r *Renderer) AppendRenderedText(text string) {
r.renderedText.WriteString(text)
}

func (r *Renderer) resetPrompt(lines int) {
// clean out current line in case tmpl didnt end in newline
cursor := r.NewCursor()
Expand All @@ -75,20 +117,41 @@ func (r *Renderer) resetPrompt(lines int) {
}
}

func (r *Renderer) Render(tmpl string, data interface{}) error {
r.resetPrompt(r.lineCount)
// render the template summarizing the current state
out, err := core.RunTemplate(tmpl, data)
if err != nil {
return err
}
func (r *Renderer) termWidth() (int, error) {
fd := int(r.stdio.Out.Fd())
termWidth, _, err := goterm.GetSize(fd)
return termWidth, err
}

// keep track of how many lines are printed so we can clean up later
r.lineCount = strings.Count(out, "\n")
// countLines will return the count of `\n` with the addition of any
// lines that have wrapped due to narrow terminal width
func (r *Renderer) countLines(buf bytes.Buffer) int {
w, err := r.termWidth()
if err != nil || w == 0 {
// if we got an error due to terminal.GetSize not being supported
// on current platform then just assume a very wide terminal
w = 10000
}

// print the summary
fmt.Fprint(terminal.NewAnsiStdout(r.stdio.Out), out)
bufBytes := buf.Bytes()

count := 0
curr := 0
delim := -1
for curr < len(bufBytes) {
// read until the next newline or the end of the string
relDelim := bytes.IndexRune(bufBytes[curr:], '\n')
if relDelim != -1 {
count += 1 // new line found, add it to the count
delim = curr + relDelim
} else {
delim = len(bufBytes) // no new line found, read rest of text
}

// account for word wrapping
count += int(utf8.RuneCount(bufBytes[curr:delim]) / w)
curr = delim + 1
}

// nothing went wrong
return nil
return count
}
2 changes: 1 addition & 1 deletion renderer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ func TestValidationError(t *testing.T) {

err := fmt.Errorf("Football is not a valid month")

actual, err := core.RunTemplate(
actual, _, err := core.RunTemplate(
ErrorTemplate,
&ErrorTemplateData{
Error: err,
Expand Down
Loading

0 comments on commit 0257161

Please sign in to comment.