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

Clear to end of screen before redrawing prompt #476

Open
wants to merge 9 commits into
base: master
Choose a base branch
from
30 changes: 15 additions & 15 deletions multiline.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@ package survey

import (
"strings"

"github.com/AlecAivazis/survey/v2/terminal"
)

type Multiline struct {
Expand All @@ -28,12 +26,12 @@ var MultilineQuestionTemplate = `
{{- color .Config.Icons.Question.Format }}{{ .Config.Icons.Question.Text }} {{color "reset"}}
{{- color "default+hb"}}{{ .Message }} {{color "reset"}}
{{- if .ShowAnswer}}
{{- "\n"}}{{color "cyan"}}{{.Answer}}{{color "reset"}}
{{- if .Answer }}{{ "\n" }}{{ end }}
{{- if .Answer}}{{"\n"}}{{color "cyan"}}{{.Answer}}{{color "reset"}}{{end}}
{{- else }}
{{- if .Default}}{{color "white"}}({{.Default}}) {{color "reset"}}{{end}}
{{- color "cyan"}}[Enter 2 empty lines to finish]{{color "reset"}}
{{- end}}`
{{- end}}
`

func (i *Multiline) Prompt(config *PromptConfig) (interface{}, error) {
// render the template
Expand Down Expand Up @@ -70,13 +68,6 @@ func (i *Multiline) Prompt(config *PromptConfig) (interface{}, error) {

if string(line) == "" {
if emptyOnce {
numLines := len(multiline) + 2
cursor.PreviousLine(numLines)
for j := 0; j < numLines; j++ {
terminal.EraseLine(i.Stdio().Out, terminal.ERASE_LINE_ALL)
cursor.NextLine(1)
}
cursor.PreviousLine(numLines)
break
}
emptyOnce = true
Expand All @@ -86,12 +77,21 @@ func (i *Multiline) Prompt(config *PromptConfig) (interface{}, error) {
multiline = append(multiline, string(line))
}

// position the cursor on last line of input
cursorPadding := 3

// ignore the empty line in an empty answer
if len(multiline) == 1 && multiline[0] == "" {
cursorPadding = 2
}
cursor.PreviousLine(cursorPadding)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's make this simpler to read at the cost of tiny repetition:

Suggested change
// position the cursor on last line of input
cursorPadding := 3
// ignore the empty line in an empty answer
if len(multiline) == 1 && multiline[0] == "" {
cursorPadding = 2
}
cursor.PreviousLine(cursorPadding)
if len(multiline) == 1 && multiline[0] == "" {
// ignore the empty line in an empty answer
cursor.PreviousLine(2)
} else {
// position the cursor on last line of input
cursor.PreviousLine(3)
}

I'm curious about where these constants 2 and 3 come from. From the old code, it looks like the number of lines to clear was calculated by taking the length of multiline and adding 2 to that (I guess to account for two blank lines). Now, it either deletes 2 or 3 lines up and not more than that. Is the size of multiline taken into account somewhere when redrawing?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Those magic numbers were a hacky way to handle the empty case and confused me quite a bit... There was something odd going on when using a modified input value while rendering the template1. Removing the newline from the answer but using the unmodified value in the template seems to clear this logic up some!

Also wanted to share that Multiline only moves the cursor location to account for the two blank lines and lets resetPrompt move the remaining n lines before clearing the screen. Really good call about checking into how multiline is redrawn!

Footnotes

  1. Removing the trailing newline from multiline before appending to renderTemplate caused this template to differ from what was actually being displayed for the empty input. The missing newline in the renderTemplate meant that countLines undercounted what was actually rendered and so cursor.PreviousLine(3) over adjusted, how odd!


// remove the trailing newline
multiline = multiline[:len(multiline)-1]
val := strings.Join(multiline, "\n")
val = strings.TrimSpace(val)

// if the line is empty
// use the default value if the line is empty
if len(val) == 0 {
// use the default value
return i.Default, err
}

Expand Down
52 changes: 23 additions & 29 deletions renderer.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,14 +42,7 @@ func (r *Renderer) NewCursor() *terminal.Cursor {
}

func (r *Renderer) Error(config *PromptConfig, invalid error) error {
// cleanup the currently rendered errors
r.resetPrompt(r.countLines(r.renderedErrors))
r.renderedErrors.Reset()

// cleanup the rest of the prompt
r.resetPrompt(r.countLines(r.renderedText))
r.renderedText.Reset()

// create a formatted and plain error template with data
userOut, layoutOut, err := core.RunTemplate(ErrorTemplate, &ErrorTemplateData{
Error: invalid,
Icon: config.Icons.Error,
Expand All @@ -58,7 +51,14 @@ func (r *Renderer) Error(config *PromptConfig, invalid error) error {
return err
}

// send the message to the user
// erase the currently rendered error and prompt
r.resetPrompt(r.countLines(r.renderedErrors))
r.renderedErrors.Reset()

r.resetPrompt(r.countLines(r.renderedText))
r.renderedText.Reset()

// print the formatted prompt
if _, err := fmt.Fprint(terminal.NewAnsiStdout(r.stdio.Out), userOut); err != nil {
return err
}
Expand All @@ -78,18 +78,17 @@ func (r *Renderer) OffsetCursor(offset int) {
}

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
// create a formatted and plain template with data
userOut, layoutOut, err := core.RunTemplate(tmpl, data)
if err != nil {
return err
}

// print the summary
// erase the currently rendered prompt
r.resetPrompt(r.countLines(r.renderedText))
r.renderedText.Reset()

// print the formatted prompt
if _, err := fmt.Fprint(terminal.NewAnsiStdout(r.stdio.Out), userOut); err != nil {
return err
}
Expand Down Expand Up @@ -130,16 +129,11 @@ func (r *Renderer) AppendRenderedText(text string) {
r.renderedText.WriteString(text)
}

// resetPrompt clears the previous lines of the past prompt
func (r *Renderer) resetPrompt(lines int) {
// clean out current line in case tmpl didnt end in newline
cursor := r.NewCursor()
cursor.HorizontalAbsolute(0)
terminal.EraseLine(r.stdio.Out, terminal.ERASE_LINE_ALL)
// clean up what we left behind last time
for i := 0; i < lines; i++ {
cursor.PreviousLine(1)
terminal.EraseLine(r.stdio.Out, terminal.ERASE_LINE_ALL)
}
cursor.PreviousLine(lines)
terminal.EraseScreen(r.stdio.Out, terminal.ERASE_SCREEN_END)
}

func (r *Renderer) termWidth() (int, error) {
Expand All @@ -161,8 +155,7 @@ func (r *Renderer) termWidthSafe() int {
// 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 := r.termWidthSafe()

termWidth := r.termWidthSafe()
bufBytes := buf.Bytes()

count := 0
Expand All @@ -179,10 +172,11 @@ func (r *Renderer) countLines(buf bytes.Buffer) int {
}

str := string(bufBytes[curr:delim])
if lineWidth := terminal.StringWidth(str); lineWidth > w {
lineWidth := terminal.StringWidth(str)
if lineWidth > termWidth {
// account for word wrapping
count += lineWidth / w
if (lineWidth % w) == 0 {
count += lineWidth / termWidth
if (lineWidth % termWidth) == 0 {
// content whose width is exactly a multiplier of available width should not
// count as having wrapped on the last line
count -= 1
Expand Down
14 changes: 10 additions & 4 deletions terminal/cursor.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,18 +47,24 @@ func (c *Cursor) Back(n int) error {

// NextLine moves cursor to beginning of the line n lines down.
func (c *Cursor) NextLine(n int) error {
if err := c.Down(1); err != nil {
if err := c.HorizontalAbsolute(0); err != nil {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why first move horizontal and then try to move between lines?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There seems to be weird behavior when going Down(0) or Up(0), where the cursor moves down or up 1 instead of remaining on the same line. I'm not sure if this is expected ANSI behavior for \x1b[0A, but this change was made to guard against this case.

It might be more appropriate to move this check for n == 0 to the Down and Up functions instead? I wasn't sure if this current behavior was relied on by other functions and felt that this change was safer.

return err
}
return c.HorizontalAbsolute(0)
if n == 0 {
return nil
}
return c.Down(n)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice catch that this didn't use to forward the n parameter and just moved down 1 line each time 👍

}

// PreviousLine moves cursor to beginning of the line n lines up.
func (c *Cursor) PreviousLine(n int) error {
if err := c.Up(1); err != nil {
if err := c.HorizontalAbsolute(0); err != nil {
return err
}
return c.HorizontalAbsolute(0)
if n == 0 {
return nil
}
return c.Up(n)
}

// HorizontalAbsolute moves cursor horizontally to x.
Expand Down
16 changes: 16 additions & 0 deletions terminal/display.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,25 @@
package terminal

import (
"fmt"
)

type EraseLineMode int
type EraseScreenMode int

const (
ERASE_LINE_END EraseLineMode = iota
ERASE_LINE_START
ERASE_LINE_ALL
)

const (
ERASE_SCREEN_END EraseScreenMode = iota
ERASE_SCREEN_START
ERASE_SCREEN_ALL
)

func EraseScreen(out FileWriter, mode EraseScreenMode) error {
_, err := fmt.Fprintf(out, "\x1b[%dJ", mode)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

on Windows we can't just assume that printing \x1b[%dJ to the terminal will just work. I'm pretty sure that out-of-the-box, that won't work in both cmd.exe and PowerShell.exe terminal, and seeing how Survey right now works in both, we would have to come up with a solution to clear to the end of the terminal that would work in all terminals that Survey currently supports.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Completely agree! I'd be interested in knowing if a modified version of this example could be used to clear only from the current cursor position to the end of the screen? I have hope that the FillConsoleOutputCharacter call is buffered and would fill the remaining screen with blanks in a single action, which should mean that option flickering is removed from select prompts on Windows, but I am unfamiliar with these calls and am open to any suggestions!

return err
}