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

Commit

Permalink
Cursor tracks select focus (#358)
Browse files Browse the repository at this point in the history
* place cursor at selected choice in select/multiselect

* use RenderWithCursorOffset

* delete old width thing

* fix cursor restoration
  • Loading branch information
Nate Smith committed Aug 23, 2021
1 parent 8a89877 commit a4e159a
Show file tree
Hide file tree
Showing 6 changed files with 350 additions and 60 deletions.
6 changes: 3 additions & 3 deletions core/template.go
Expand Up @@ -29,7 +29,7 @@ var TemplateFuncsNoColor = map[string]interface{}{
//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)
tPair, err := GetTemplatePair(tmpl)
if err != nil {
return "", "", err
}
Expand All @@ -52,12 +52,12 @@ var (
memoMutex = &sync.RWMutex{}
)

//getTemplatePair returns a pair of compiled templates where the
//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) {
func GetTemplatePair(tmpl string) ([2]*template.Template, error) {
memoMutex.RLock()
if t, ok := memoizedGetTemplate[tmpl]; ok {
memoMutex.RUnlock()
Expand Down
69 changes: 42 additions & 27 deletions multiselect.go
Expand Up @@ -45,9 +45,27 @@ type MultiSelectTemplateData struct {
ShowHelp bool
PageEntries []core.OptionAnswer
Config *PromptConfig

// These fields are used when rendering an individual option
CurrentOpt core.OptionAnswer
CurrentIndex int
}

// IterateOption sets CurrentOpt and CurrentIndex appropriately so a multiselect option can be rendered individually
func (m MultiSelectTemplateData) IterateOption(ix int, opt core.OptionAnswer) interface{} {
copy := m
copy.CurrentIndex = ix
copy.CurrentOpt = opt
return copy
}

var MultiSelectQuestionTemplate = `
{{- define "option"}}
{{- if eq .SelectedIndex .CurrentIndex }}{{color .Config.Icons.SelectFocus.Format }}{{ .Config.Icons.SelectFocus.Text }}{{color "reset"}}{{else}} {{end}}
{{- if index .Checked .CurrentOpt.Index }}{{color .Config.Icons.MarkedOption.Format }} {{ .Config.Icons.MarkedOption.Text }} {{else}}{{color .Config.Icons.UnmarkedOption.Format }} {{ .Config.Icons.UnmarkedOption.Text }} {{end}}
{{- color "reset"}}
{{- " "}}{{- .CurrentOpt.Value}}
{{end}}
{{- if .ShowHelp }}{{- color .Config.Icons.Help.Format }}{{ .Config.Icons.Help.Text }} {{ .Help }}{{color "reset"}}{{"\n"}}{{end}}
{{- color .Config.Icons.Question.Format }}{{ .Config.Icons.Question.Text }} {{color "reset"}}
{{- color "default+hb"}}{{ .Message }}{{ .FilterMessage }}{{color "reset"}}
Expand All @@ -56,10 +74,7 @@ var MultiSelectQuestionTemplate = `
{{- " "}}{{- color "cyan"}}[Use arrows to move, space to select, <right> to all, <left> to none, type to filter{{- if and .Help (not .ShowHelp)}}, {{ .Config.HelpInput }} for more help{{end}}]{{color "reset"}}
{{- "\n"}}
{{- range $ix, $option := .PageEntries}}
{{- if eq $ix $.SelectedIndex }}{{color $.Config.Icons.SelectFocus.Format }}{{ $.Config.Icons.SelectFocus.Text }}{{color "reset"}}{{else}} {{end}}
{{- if index $.Checked $option.Index }}{{color $.Config.Icons.MarkedOption.Format }} {{ $.Config.Icons.MarkedOption.Text }} {{else}}{{color $.Config.Icons.UnmarkedOption.Format }} {{ $.Config.Icons.UnmarkedOption.Text }} {{end}}
{{- color "reset"}}
{{- " "}}{{$option.Value}}{{"\n"}}
{{- template "option" $.IterateOption $ix $option}}
{{- end}}
{{- end}}`

Expand Down Expand Up @@ -159,18 +174,17 @@ func (m *MultiSelect) OnChange(key rune, config *PromptConfig) {
// and we have modified the filter then we should move the page back!
opts, idx := paginate(pageSize, options, m.selectedIndex)

tmplData := MultiSelectTemplateData{
MultiSelect: *m,
SelectedIndex: idx,
Checked: m.checked,
ShowHelp: m.showingHelp,
PageEntries: opts,
Config: config,
}

// render the options
m.Render(
MultiSelectQuestionTemplate,
MultiSelectTemplateData{
MultiSelect: *m,
SelectedIndex: idx,
Checked: m.checked,
ShowHelp: m.showingHelp,
PageEntries: opts,
Config: config,
},
)
m.RenderWithCursorOffset(MultiSelectQuestionTemplate, tmplData, opts, idx)
}

func (m *MultiSelect) filterOptions(config *PromptConfig) []core.OptionAnswer {
Expand Down Expand Up @@ -250,20 +264,21 @@ func (m *MultiSelect) Prompt(config *PromptConfig) (interface{}, error) {
opts, idx := paginate(pageSize, core.OptionAnswerList(m.Options), m.selectedIndex)

cursor := m.NewCursor()
cursor.Hide() // hide the cursor
defer cursor.Show() // show the cursor when we're done
cursor.Save() // for proper cursor placement during selection
cursor.Hide() // hide the cursor
defer cursor.Show() // show the cursor when we're done
defer cursor.Restore() // clear any accessibility offsetting on exit

tmplData := MultiSelectTemplateData{
MultiSelect: *m,
SelectedIndex: idx,
Checked: m.checked,
PageEntries: opts,
Config: config,
}

// ask the question
err := m.Render(
MultiSelectQuestionTemplate,
MultiSelectTemplateData{
MultiSelect: *m,
SelectedIndex: idx,
Checked: m.checked,
PageEntries: opts,
Config: config,
},
)
err := m.RenderWithCursorOffset(MultiSelectQuestionTemplate, tmplData, opts, idx)
if err != nil {
return "", err
}
Expand Down
34 changes: 31 additions & 3 deletions renderer.go
Expand Up @@ -69,6 +69,14 @@ func (r *Renderer) Error(config *PromptConfig, invalid error) error {
return nil
}

func (r *Renderer) OffsetCursor(offset int) {
cursor := r.NewCursor()
for offset > 0 {
cursor.PreviousLine(-1)
offset--
}
}

func (r *Renderer) Render(tmpl string, data interface{}) error {
// cleanup the currently rendered text
lineCount := r.countLines(r.renderedText)
Expand All @@ -91,6 +99,21 @@ func (r *Renderer) Render(tmpl string, data interface{}) error {
return nil
}

func (r *Renderer) RenderWithCursorOffset(tmpl string, data IterableOpts, opts []core.OptionAnswer, idx int) error {
cursor := r.NewCursor()
cursor.Restore() // clear any accessibility offsetting

if err := r.Render(tmpl, data); err != nil {
return err
}
cursor.Save()

offset := computeCursorOffset(MultiSelectQuestionTemplate, data, opts, idx, r.termWidthSafe())
r.OffsetCursor(offset)

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).
Expand Down Expand Up @@ -123,15 +146,20 @@ func (r *Renderer) termWidth() (int, error) {
return termWidth, err
}

// 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 {
func (r *Renderer) termWidthSafe() 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
}
return w
}

// 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()

bufBytes := buf.Bytes()

Expand Down
72 changes: 45 additions & 27 deletions select.go
Expand Up @@ -43,20 +43,35 @@ type SelectTemplateData struct {
ShowAnswer bool
ShowHelp bool
Config *PromptConfig

// These fields are used when rendering an individual option
CurrentOpt core.OptionAnswer
CurrentIndex int
}

// IterateOption sets CurrentOpt and CurrentIndex appropriately so a select option can be rendered individually
func (s SelectTemplateData) IterateOption(ix int, opt core.OptionAnswer) interface{} {
copy := s
copy.CurrentIndex = ix
copy.CurrentOpt = opt
return copy
}

var SelectQuestionTemplate = `
{{- define "option"}}
{{- if eq .SelectedIndex .CurrentIndex }}{{color .Config.Icons.SelectFocus.Format }}{{ .Config.Icons.SelectFocus.Text }} {{else}}{{color "default"}} {{end}}
{{- .CurrentOpt.Value}}
{{- color "reset"}}
{{end}}
{{- if .ShowHelp }}{{- color .Config.Icons.Help.Format }}{{ .Config.Icons.Help.Text }} {{ .Help }}{{color "reset"}}{{"\n"}}{{end}}
{{- color .Config.Icons.Question.Format }}{{ .Config.Icons.Question.Text }} {{color "reset"}}
{{- color "default+hb"}}{{ .Message }}{{ .FilterMessage }}{{color "reset"}}
{{- if .ShowAnswer}}{{color "cyan"}} {{.Answer}}{{color "reset"}}{{"\n"}}
{{- else}}
{{- " "}}{{- color "cyan"}}[Use arrows to move, type to filter{{- if and .Help (not .ShowHelp)}}, {{ .Config.HelpInput }} for more help{{end}}]{{color "reset"}}
{{- "\n"}}
{{- range $ix, $choice := .PageEntries}}
{{- if eq $ix $.SelectedIndex }}{{color $.Config.Icons.SelectFocus.Format }}{{ $.Config.Icons.SelectFocus.Text }} {{else}}{{color "default"}} {{end}}
{{- $choice.Value}}
{{- color "reset"}}{{"\n"}}
{{- range $ix, $option := .PageEntries}}
{{- template "option" $.IterateOption $ix $option}}
{{- end}}
{{- end}}`

Expand Down Expand Up @@ -152,17 +167,16 @@ func (s *Select) OnChange(key rune, config *PromptConfig) bool {
// and we have modified the filter then we should move the page back!
opts, idx := paginate(pageSize, options, s.selectedIndex)

tmplData := SelectTemplateData{
Select: *s,
SelectedIndex: idx,
ShowHelp: s.showingHelp,
PageEntries: opts,
Config: config,
}

// render the options
s.Render(
SelectQuestionTemplate,
SelectTemplateData{
Select: *s,
SelectedIndex: idx,
ShowHelp: s.showingHelp,
PageEntries: opts,
Config: config,
},
)
s.RenderWithCursorOffset(SelectQuestionTemplate, tmplData, opts, idx)

// keep prompting
return false
Expand Down Expand Up @@ -234,16 +248,22 @@ func (s *Select) Prompt(config *PromptConfig) (interface{}, error) {
// figure out the options and index to render
opts, idx := paginate(pageSize, core.OptionAnswerList(s.Options), sel)

cursor := s.NewCursor()
cursor.Save() // for proper cursor placement during selection
cursor.Hide() // hide the cursor
defer cursor.Show() // show the cursor when we're done
defer cursor.Restore() // clear any accessibility offsetting on exit

tmplData := SelectTemplateData{
Select: *s,
SelectedIndex: idx,
ShowHelp: s.showingHelp,
PageEntries: opts,
Config: config,
}

// ask the question
err := s.Render(
SelectQuestionTemplate,
SelectTemplateData{
Select: *s,
PageEntries: opts,
SelectedIndex: idx,
Config: config,
},
)
err := s.RenderWithCursorOffset(SelectQuestionTemplate, tmplData, opts, idx)
if err != nil {
return "", err
}
Expand All @@ -255,10 +275,6 @@ func (s *Select) Prompt(config *PromptConfig) (interface{}, error) {
rr.SetTermMode()
defer rr.RestoreTermMode()

cursor := s.NewCursor()
cursor.Hide() // hide the cursor
defer cursor.Show() // show the cursor when we're done

// start waiting for input
for {
r, _, err := rr.ReadRune()
Expand Down Expand Up @@ -317,6 +333,8 @@ func (s *Select) Prompt(config *PromptConfig) (interface{}, error) {
}

func (s *Select) Cleanup(config *PromptConfig, val interface{}) error {
cursor := s.NewCursor()
cursor.Restore()
return s.Render(
SelectQuestionTemplate,
SelectTemplateData{
Expand Down
41 changes: 41 additions & 0 deletions survey.go
@@ -1,10 +1,12 @@
package survey

import (
"bytes"
"errors"
"io"
"os"
"strings"
"unicode/utf8"

"github.com/AlecAivazis/survey/v2/core"
"github.com/AlecAivazis/survey/v2/terminal"
Expand Down Expand Up @@ -411,3 +413,42 @@ func paginate(pageSize int, choices []core.OptionAnswer, sel int) ([]core.Option
// return the subset we care about and the index
return choices[start:end], cursor
}

type IterableOpts interface {
IterateOption(int, core.OptionAnswer) interface{}
}

func computeCursorOffset(tmpl string, data IterableOpts, opts []core.OptionAnswer, idx, tWidth int) int {
tmpls, err := core.GetTemplatePair(tmpl)

if err != nil {
return 0
}

t := tmpls[0]

renderOpt := func(ix int, opt core.OptionAnswer) string {
buf := bytes.NewBufferString("")
t.ExecuteTemplate(buf, "option", data.IterateOption(ix, opt))
return buf.String()
}

offset := len(opts) - idx

for i, o := range opts {
if i < idx {
continue
}
renderedOpt := renderOpt(i, o)
valWidth := utf8.RuneCount([]byte(renderedOpt))
if valWidth > tWidth {
splitCount := valWidth / tWidth
if valWidth%tWidth == 0 {
splitCount -= 1
}
offset += splitCount
}
}

return offset
}

1 comment on commit a4e159a

@alex19EP
Copy link

Choose a reason for hiding this comment

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

yay! thanks for this fix

Please sign in to comment.