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

first pass on cursor tracking select focus #358

Merged
merged 4 commits into from Aug 23, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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
}