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

Commit a4e159a

Browse files
author
Nate Smith
authored
Cursor tracks select focus (#358)
* place cursor at selected choice in select/multiselect * use RenderWithCursorOffset * delete old width thing * fix cursor restoration
1 parent 8a89877 commit a4e159a

File tree

6 files changed

+350
-60
lines changed

6 files changed

+350
-60
lines changed

core/template.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ var TemplateFuncsNoColor = map[string]interface{}{
2929
//for colored output. The second string does not contain escape codes
3030
//and can be used by the renderer for layout purposes.
3131
func RunTemplate(tmpl string, data interface{}) (string, string, error) {
32-
tPair, err := getTemplatePair(tmpl)
32+
tPair, err := GetTemplatePair(tmpl)
3333
if err != nil {
3434
return "", "", err
3535
}
@@ -52,12 +52,12 @@ var (
5252
memoMutex = &sync.RWMutex{}
5353
)
5454

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

multiselect.go

Lines changed: 42 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -45,9 +45,27 @@ type MultiSelectTemplateData struct {
4545
ShowHelp bool
4646
PageEntries []core.OptionAnswer
4747
Config *PromptConfig
48+
49+
// These fields are used when rendering an individual option
50+
CurrentOpt core.OptionAnswer
51+
CurrentIndex int
52+
}
53+
54+
// IterateOption sets CurrentOpt and CurrentIndex appropriately so a multiselect option can be rendered individually
55+
func (m MultiSelectTemplateData) IterateOption(ix int, opt core.OptionAnswer) interface{} {
56+
copy := m
57+
copy.CurrentIndex = ix
58+
copy.CurrentOpt = opt
59+
return copy
4860
}
4961

5062
var MultiSelectQuestionTemplate = `
63+
{{- define "option"}}
64+
{{- if eq .SelectedIndex .CurrentIndex }}{{color .Config.Icons.SelectFocus.Format }}{{ .Config.Icons.SelectFocus.Text }}{{color "reset"}}{{else}} {{end}}
65+
{{- 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}}
66+
{{- color "reset"}}
67+
{{- " "}}{{- .CurrentOpt.Value}}
68+
{{end}}
5169
{{- if .ShowHelp }}{{- color .Config.Icons.Help.Format }}{{ .Config.Icons.Help.Text }} {{ .Help }}{{color "reset"}}{{"\n"}}{{end}}
5270
{{- color .Config.Icons.Question.Format }}{{ .Config.Icons.Question.Text }} {{color "reset"}}
5371
{{- color "default+hb"}}{{ .Message }}{{ .FilterMessage }}{{color "reset"}}
@@ -56,10 +74,7 @@ var MultiSelectQuestionTemplate = `
5674
{{- " "}}{{- 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"}}
5775
{{- "\n"}}
5876
{{- range $ix, $option := .PageEntries}}
59-
{{- if eq $ix $.SelectedIndex }}{{color $.Config.Icons.SelectFocus.Format }}{{ $.Config.Icons.SelectFocus.Text }}{{color "reset"}}{{else}} {{end}}
60-
{{- 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}}
61-
{{- color "reset"}}
62-
{{- " "}}{{$option.Value}}{{"\n"}}
77+
{{- template "option" $.IterateOption $ix $option}}
6378
{{- end}}
6479
{{- end}}`
6580

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

177+
tmplData := MultiSelectTemplateData{
178+
MultiSelect: *m,
179+
SelectedIndex: idx,
180+
Checked: m.checked,
181+
ShowHelp: m.showingHelp,
182+
PageEntries: opts,
183+
Config: config,
184+
}
185+
162186
// render the options
163-
m.Render(
164-
MultiSelectQuestionTemplate,
165-
MultiSelectTemplateData{
166-
MultiSelect: *m,
167-
SelectedIndex: idx,
168-
Checked: m.checked,
169-
ShowHelp: m.showingHelp,
170-
PageEntries: opts,
171-
Config: config,
172-
},
173-
)
187+
m.RenderWithCursorOffset(MultiSelectQuestionTemplate, tmplData, opts, idx)
174188
}
175189

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

252266
cursor := m.NewCursor()
253-
cursor.Hide() // hide the cursor
254-
defer cursor.Show() // show the cursor when we're done
267+
cursor.Save() // for proper cursor placement during selection
268+
cursor.Hide() // hide the cursor
269+
defer cursor.Show() // show the cursor when we're done
270+
defer cursor.Restore() // clear any accessibility offsetting on exit
271+
272+
tmplData := MultiSelectTemplateData{
273+
MultiSelect: *m,
274+
SelectedIndex: idx,
275+
Checked: m.checked,
276+
PageEntries: opts,
277+
Config: config,
278+
}
255279

256280
// ask the question
257-
err := m.Render(
258-
MultiSelectQuestionTemplate,
259-
MultiSelectTemplateData{
260-
MultiSelect: *m,
261-
SelectedIndex: idx,
262-
Checked: m.checked,
263-
PageEntries: opts,
264-
Config: config,
265-
},
266-
)
281+
err := m.RenderWithCursorOffset(MultiSelectQuestionTemplate, tmplData, opts, idx)
267282
if err != nil {
268283
return "", err
269284
}

renderer.go

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,14 @@ func (r *Renderer) Error(config *PromptConfig, invalid error) error {
6969
return nil
7070
}
7171

72+
func (r *Renderer) OffsetCursor(offset int) {
73+
cursor := r.NewCursor()
74+
for offset > 0 {
75+
cursor.PreviousLine(-1)
76+
offset--
77+
}
78+
}
79+
7280
func (r *Renderer) Render(tmpl string, data interface{}) error {
7381
// cleanup the currently rendered text
7482
lineCount := r.countLines(r.renderedText)
@@ -91,6 +99,21 @@ func (r *Renderer) Render(tmpl string, data interface{}) error {
9199
return nil
92100
}
93101

102+
func (r *Renderer) RenderWithCursorOffset(tmpl string, data IterableOpts, opts []core.OptionAnswer, idx int) error {
103+
cursor := r.NewCursor()
104+
cursor.Restore() // clear any accessibility offsetting
105+
106+
if err := r.Render(tmpl, data); err != nil {
107+
return err
108+
}
109+
cursor.Save()
110+
111+
offset := computeCursorOffset(MultiSelectQuestionTemplate, data, opts, idx, r.termWidthSafe())
112+
r.OffsetCursor(offset)
113+
114+
return nil
115+
}
116+
94117
// appendRenderedError appends text to the renderer's error buffer
95118
// which is used to track what has been printed. It is not exported
96119
// as errors should only be displayed via Error(config, error).
@@ -123,15 +146,20 @@ func (r *Renderer) termWidth() (int, error) {
123146
return termWidth, err
124147
}
125148

126-
// countLines will return the count of `\n` with the addition of any
127-
// lines that have wrapped due to narrow terminal width
128-
func (r *Renderer) countLines(buf bytes.Buffer) int {
149+
func (r *Renderer) termWidthSafe() int {
129150
w, err := r.termWidth()
130151
if err != nil || w == 0 {
131152
// if we got an error due to terminal.GetSize not being supported
132153
// on current platform then just assume a very wide terminal
133154
w = 10000
134155
}
156+
return w
157+
}
158+
159+
// countLines will return the count of `\n` with the addition of any
160+
// lines that have wrapped due to narrow terminal width
161+
func (r *Renderer) countLines(buf bytes.Buffer) int {
162+
w := r.termWidthSafe()
135163

136164
bufBytes := buf.Bytes()
137165

select.go

Lines changed: 45 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -43,20 +43,35 @@ type SelectTemplateData struct {
4343
ShowAnswer bool
4444
ShowHelp bool
4545
Config *PromptConfig
46+
47+
// These fields are used when rendering an individual option
48+
CurrentOpt core.OptionAnswer
49+
CurrentIndex int
50+
}
51+
52+
// IterateOption sets CurrentOpt and CurrentIndex appropriately so a select option can be rendered individually
53+
func (s SelectTemplateData) IterateOption(ix int, opt core.OptionAnswer) interface{} {
54+
copy := s
55+
copy.CurrentIndex = ix
56+
copy.CurrentOpt = opt
57+
return copy
4658
}
4759

4860
var SelectQuestionTemplate = `
61+
{{- define "option"}}
62+
{{- if eq .SelectedIndex .CurrentIndex }}{{color .Config.Icons.SelectFocus.Format }}{{ .Config.Icons.SelectFocus.Text }} {{else}}{{color "default"}} {{end}}
63+
{{- .CurrentOpt.Value}}
64+
{{- color "reset"}}
65+
{{end}}
4966
{{- if .ShowHelp }}{{- color .Config.Icons.Help.Format }}{{ .Config.Icons.Help.Text }} {{ .Help }}{{color "reset"}}{{"\n"}}{{end}}
5067
{{- color .Config.Icons.Question.Format }}{{ .Config.Icons.Question.Text }} {{color "reset"}}
5168
{{- color "default+hb"}}{{ .Message }}{{ .FilterMessage }}{{color "reset"}}
5269
{{- if .ShowAnswer}}{{color "cyan"}} {{.Answer}}{{color "reset"}}{{"\n"}}
5370
{{- else}}
5471
{{- " "}}{{- color "cyan"}}[Use arrows to move, type to filter{{- if and .Help (not .ShowHelp)}}, {{ .Config.HelpInput }} for more help{{end}}]{{color "reset"}}
5572
{{- "\n"}}
56-
{{- range $ix, $choice := .PageEntries}}
57-
{{- if eq $ix $.SelectedIndex }}{{color $.Config.Icons.SelectFocus.Format }}{{ $.Config.Icons.SelectFocus.Text }} {{else}}{{color "default"}} {{end}}
58-
{{- $choice.Value}}
59-
{{- color "reset"}}{{"\n"}}
73+
{{- range $ix, $option := .PageEntries}}
74+
{{- template "option" $.IterateOption $ix $option}}
6075
{{- end}}
6176
{{- end}}`
6277

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

170+
tmplData := SelectTemplateData{
171+
Select: *s,
172+
SelectedIndex: idx,
173+
ShowHelp: s.showingHelp,
174+
PageEntries: opts,
175+
Config: config,
176+
}
177+
155178
// render the options
156-
s.Render(
157-
SelectQuestionTemplate,
158-
SelectTemplateData{
159-
Select: *s,
160-
SelectedIndex: idx,
161-
ShowHelp: s.showingHelp,
162-
PageEntries: opts,
163-
Config: config,
164-
},
165-
)
179+
s.RenderWithCursorOffset(SelectQuestionTemplate, tmplData, opts, idx)
166180

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

251+
cursor := s.NewCursor()
252+
cursor.Save() // for proper cursor placement during selection
253+
cursor.Hide() // hide the cursor
254+
defer cursor.Show() // show the cursor when we're done
255+
defer cursor.Restore() // clear any accessibility offsetting on exit
256+
257+
tmplData := SelectTemplateData{
258+
Select: *s,
259+
SelectedIndex: idx,
260+
ShowHelp: s.showingHelp,
261+
PageEntries: opts,
262+
Config: config,
263+
}
264+
237265
// ask the question
238-
err := s.Render(
239-
SelectQuestionTemplate,
240-
SelectTemplateData{
241-
Select: *s,
242-
PageEntries: opts,
243-
SelectedIndex: idx,
244-
Config: config,
245-
},
246-
)
266+
err := s.RenderWithCursorOffset(SelectQuestionTemplate, tmplData, opts, idx)
247267
if err != nil {
248268
return "", err
249269
}
@@ -255,10 +275,6 @@ func (s *Select) Prompt(config *PromptConfig) (interface{}, error) {
255275
rr.SetTermMode()
256276
defer rr.RestoreTermMode()
257277

258-
cursor := s.NewCursor()
259-
cursor.Hide() // hide the cursor
260-
defer cursor.Show() // show the cursor when we're done
261-
262278
// start waiting for input
263279
for {
264280
r, _, err := rr.ReadRune()
@@ -317,6 +333,8 @@ func (s *Select) Prompt(config *PromptConfig) (interface{}, error) {
317333
}
318334

319335
func (s *Select) Cleanup(config *PromptConfig, val interface{}) error {
336+
cursor := s.NewCursor()
337+
cursor.Restore()
320338
return s.Render(
321339
SelectQuestionTemplate,
322340
SelectTemplateData{

survey.go

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
package survey
22

33
import (
4+
"bytes"
45
"errors"
56
"io"
67
"os"
78
"strings"
9+
"unicode/utf8"
810

911
"github.com/AlecAivazis/survey/v2/core"
1012
"github.com/AlecAivazis/survey/v2/terminal"
@@ -411,3 +413,42 @@ func paginate(pageSize int, choices []core.OptionAnswer, sel int) ([]core.Option
411413
// return the subset we care about and the index
412414
return choices[start:end], cursor
413415
}
416+
417+
type IterableOpts interface {
418+
IterateOption(int, core.OptionAnswer) interface{}
419+
}
420+
421+
func computeCursorOffset(tmpl string, data IterableOpts, opts []core.OptionAnswer, idx, tWidth int) int {
422+
tmpls, err := core.GetTemplatePair(tmpl)
423+
424+
if err != nil {
425+
return 0
426+
}
427+
428+
t := tmpls[0]
429+
430+
renderOpt := func(ix int, opt core.OptionAnswer) string {
431+
buf := bytes.NewBufferString("")
432+
t.ExecuteTemplate(buf, "option", data.IterateOption(ix, opt))
433+
return buf.String()
434+
}
435+
436+
offset := len(opts) - idx
437+
438+
for i, o := range opts {
439+
if i < idx {
440+
continue
441+
}
442+
renderedOpt := renderOpt(i, o)
443+
valWidth := utf8.RuneCount([]byte(renderedOpt))
444+
if valWidth > tWidth {
445+
splitCount := valWidth / tWidth
446+
if valWidth%tWidth == 0 {
447+
splitCount -= 1
448+
}
449+
offset += splitCount
450+
}
451+
}
452+
453+
return offset
454+
}

0 commit comments

Comments
 (0)