Skip to content
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.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 40 additions & 23 deletions field_multiselect.go
Original file line number Diff line number Diff line change
Expand Up @@ -454,9 +454,12 @@ func (m *MultiSelect[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
if len(m.filteredOptions) > 0 {
m.cursor = min(m.cursor, len(m.filteredOptions)-1)
m.viewport.SetYOffset(clamp(m.cursor, 0, len(m.filteredOptions)-m.viewport.Height))
}
}
_, offset, height := m.optionsView()
if offset > -1 && height > 0 && (offset < m.viewport.YOffset || height+offset >= m.viewport.YOffset+m.viewport.Height) {
m.viewport.SetYOffset(offset)
}
}

return m, tea.Batch(cmds...)
Expand All @@ -465,9 +468,10 @@ func (m *MultiSelect[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// updateViewportHeight updates the viewport size according to the Height setting
// on this multi-select field.
func (m *MultiSelect[T]) updateViewportHeight() {
// If no height is set size the viewport to the number of options.
// If no height is set size the viewport to the height of the options.
if m.height <= 0 {
m.viewport.Height = len(m.options.val)
s, _, _ := m.optionsView()
m.viewport.Height = lipgloss.Height(s)
return
}

Expand Down Expand Up @@ -547,33 +551,45 @@ func (m *MultiSelect[T]) descriptionView() string {
return m.activeStyles().Description.Render(m.description.val)
}

func (m *MultiSelect[T]) optionsView() string {
var (
styles = m.activeStyles()
c = styles.MultiSelectSelector.String()
sb strings.Builder
)
func (m *MultiSelect[T]) renderOption(option Option[T], cursor, selected bool) string {
styles := m.activeStyles()
var parts []string
if cursor {
parts = append(parts, styles.MultiSelectSelector.String())
} else {
parts = append(parts, strings.Repeat(" ", lipgloss.Width(styles.MultiSelectSelector.String())))
}
if selected {
parts = append(parts, styles.SelectedPrefix.String())
parts = append(parts, styles.SelectedOption.Render(option.Key))
} else {
parts = append(parts, styles.UnselectedPrefix.String())
parts = append(parts, styles.UnselectedOption.Render(option.Key))
}
return lipgloss.JoinHorizontal(lipgloss.Top, parts...)
}

func (m *MultiSelect[T]) optionsView() (string, int, int) {
var sb strings.Builder

if m.options.loading && time.Since(m.options.loadingStart) > spinnerShowThreshold {
m.spinner.Style = m.activeStyles().MultiSelectSelector.UnsetString()
sb.WriteString(m.spinner.View() + " Loading...")
return sb.String()
return sb.String(), -1, 1
}

var cursorOffset int
var cursorHeight int
for i, option := range m.filteredOptions {
if m.cursor == i {
sb.WriteString(c)
} else {
sb.WriteString(strings.Repeat(" ", lipgloss.Width(c)))
cursor := m.cursor == i
line := m.renderOption(option, cursor, m.filteredOptions[i].selected)
if i < m.cursor {
cursorOffset += lipgloss.Height(line)
}

if m.filteredOptions[i].selected {
sb.WriteString(styles.SelectedPrefix.String())
sb.WriteString(styles.SelectedOption.Render(option.Key))
} else {
sb.WriteString(styles.UnselectedPrefix.String())
sb.WriteString(styles.UnselectedOption.Render(option.Key))
if cursor {
cursorHeight = lipgloss.Height(line)
}
sb.WriteString(line)
if i < len(m.options.val)-1 {
sb.WriteString("\n")
}
Expand All @@ -583,14 +599,15 @@ func (m *MultiSelect[T]) optionsView() string {
sb.WriteString("\n")
}

return sb.String()
return sb.String(), cursorOffset, cursorHeight
}

// View renders the multi-select field.
func (m *MultiSelect[T]) View() string {
styles := m.activeStyles()

m.viewport.SetContent(m.optionsView())
vpc, _, _ := m.optionsView()
m.viewport.SetContent(vpc)

var sb strings.Builder
if m.title.val != "" || m.title.fn != nil {
Expand Down
61 changes: 44 additions & 17 deletions field_select.go
Original file line number Diff line number Diff line change
Expand Up @@ -320,11 +320,6 @@ func (s *Select[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
if s.filtering {
s.filter, cmd = s.filter.Update(msg)

// Keep the selected item in view.
if s.selected < s.viewport.YOffset || s.selected >= s.viewport.YOffset+s.viewport.Height {
s.viewport.SetYOffset(s.selected)
}
}

switch msg := msg.(type) {
Expand Down Expand Up @@ -496,9 +491,13 @@ func (s *Select[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
if len(s.filteredOptions) > 0 {
s.selected = min(s.selected, len(s.filteredOptions)-1)
s.viewport.SetYOffset(clamp(s.selected, 0, len(s.filteredOptions)-s.viewport.Height))
}
}

_, offset, height := s.optionsView()
if offset > -1 && height > 0 && (offset < s.viewport.YOffset || height+offset >= s.viewport.YOffset+s.viewport.Height) {
s.viewport.SetYOffset(offset)
}
}

return s, cmd
Expand All @@ -515,7 +514,8 @@ func (s *Select[T]) updateValue() {
func (s *Select[T]) updateViewportHeight() {
// If no height is set size the viewport to the number of options.
if s.height <= 0 {
s.viewport.Height = len(s.options.val)
v, _, _ := s.optionsView()
s.viewport.Height = lipgloss.Height(v)
return
}

Expand Down Expand Up @@ -557,17 +557,16 @@ func (s *Select[T]) descriptionView() string {
return s.activeStyles().Description.Render(s.description.val)
}

func (s *Select[T]) optionsView() string {
func (s *Select[T]) optionsView() (string, int, int) {
var (
styles = s.activeStyles()
c = styles.SelectSelector.String()
sb strings.Builder
)

if s.options.loading && time.Since(s.options.loadingStart) > spinnerShowThreshold {
s.spinner.Style = s.activeStyles().MultiSelectSelector.UnsetString()
sb.WriteString(s.spinner.View() + " Loading...")
return sb.String()
return sb.String(), -1, 1
}

if s.inline {
Expand All @@ -578,15 +577,22 @@ func (s *Select[T]) optionsView() string {
sb.WriteString(styles.TextInput.Placeholder.Render("No matches"))
}
sb.WriteString(styles.NextIndicator.Faint(s.selected == len(s.filteredOptions)-1).String())
return sb.String()
return sb.String(), -1, 1
}

var cursorOffset int
var cursorHeight int
for i, option := range s.filteredOptions {
if s.selected == i {
sb.WriteString(c + styles.SelectedOption.Render(option.Key))
} else {
sb.WriteString(strings.Repeat(" ", lipgloss.Width(c)) + styles.UnselectedOption.Render(option.Key))
selected := s.selected == i
line := s.renderOption(option, selected)
if i < s.selected {
cursorOffset += lipgloss.Height(line)
}
if selected {
cursorHeight = lipgloss.Height(line)
}

sb.WriteString(line)
if i < len(s.options.val)-1 {
sb.WriteString("\n")
}
Expand All @@ -596,13 +602,34 @@ func (s *Select[T]) optionsView() string {
sb.WriteString("\n")
}

return sb.String()
return sb.String(), cursorOffset, cursorHeight
}

func (s *Select[T]) renderOption(option Option[T], selected bool) string {
var (
styles = s.activeStyles()
cursor = styles.SelectSelector.String()
)

if selected {
return lipgloss.JoinHorizontal(
lipgloss.Top,
cursor,
styles.SelectedOption.Render(option.Key),
)
}
return lipgloss.JoinHorizontal(
lipgloss.Top,
strings.Repeat(" ", lipgloss.Width(cursor)),
styles.UnselectedOption.Render(option.Key),
)
}

// View renders the select field.
func (s *Select[T]) View() string {
styles := s.activeStyles()
s.viewport.SetContent(s.optionsView())
vpc, _, _ := s.optionsView()
s.viewport.SetContent(vpc)

var sb strings.Builder
if s.title.val != "" || s.title.fn != nil {
Expand Down
43 changes: 28 additions & 15 deletions huh_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -434,23 +434,30 @@ func TestConfirm(t *testing.T) {
}

// Toggle left
m, _ = f.Update(tea.KeyMsg{Type: tea.KeyLeft})
f.Update(tea.KeyMsg{Type: tea.KeyLeft})

if field.GetValue() != true {
t.Error("Expected field value to be true")
}

// Toggle right
m, _ = f.Update(tea.KeyMsg{Type: tea.KeyRight})
f.Update(tea.KeyMsg{Type: tea.KeyRight})

if field.GetValue() != false {
t.Error("Expected field value to be false")
}
}

func TestSelect(t *testing.T) {
field := NewSelect[string]().Options(NewOptions("Foo", "Bar", "Baz")...).Title("Which one?")
f := NewForm(NewGroup(field))
field := NewSelect[string]().
Options(NewOptions(
"Foo\nLine 2",
"Bar\nLine 2",
"Baz\nLine 2",
"Ban\nLine 2",
)...).
Title("Which one?")
f := NewForm(NewGroup(field)).WithHeight(5)
f.Update(f.Init())

view := ansi.Strip(f.View())
Expand All @@ -476,7 +483,7 @@ func TestSelect(t *testing.T) {

view = ansi.Strip(f.View())

if got, ok := field.Hovered(); !ok || got != "Bar" {
if got, ok := field.Hovered(); !ok || got != "Bar\nLine 2" {
t.Log(pretty.Render(view))
t.Error("Expected cursor to be on Bar.")
}
Expand All @@ -497,17 +504,24 @@ func TestSelect(t *testing.T) {
}

// Submit
m, _ = f.Update(tea.KeyMsg{Type: tea.KeyEnter})
f = m.(*Form)
f.Update(tea.KeyMsg{Type: tea.KeyEnter})

if field.GetValue() != "Bar" {
if field.GetValue() != "Bar\nLine 2" {
t.Error("Expected field value to be Bar")
}
}

func TestMultiSelect(t *testing.T) {
field := NewMultiSelect[string]().Options(NewOptions("Foo", "Bar", "Baz")...).Title("Which one?")
f := NewForm(NewGroup(field))
field := NewMultiSelect[string]().
Options(NewOptions(
"Foo\nLine2",
"Bar\nLine2",
"Baz\nLine2",
"Ban\nLine2",
)...).
Title("Which one?")
f := NewForm(NewGroup(field)).
WithHeight(5)
f.Update(f.Init())

view := ansi.Strip(f.View())
Expand All @@ -531,7 +545,7 @@ func TestMultiSelect(t *testing.T) {
m, _ := f.Update(keys('j'))
view = ansi.Strip(m.View())

if got, ok := field.Hovered(); !ok || got != "Bar" {
if got, ok := field.Hovered(); !ok || got != "Bar\nLine2" {
t.Log(pretty.Render(view))
t.Error("Expected cursor to be on Bar.")
}
Expand Down Expand Up @@ -561,8 +575,7 @@ func TestMultiSelect(t *testing.T) {
}

// Submit
m, _ = f.Update(tea.KeyMsg{Type: tea.KeyEnter})
f = m.(*Form)
f.Update(tea.KeyMsg{Type: tea.KeyEnter})

value := field.GetValue()
if value, ok := value.([]string); !ok {
Expand All @@ -571,8 +584,8 @@ func TestMultiSelect(t *testing.T) {
if len(value) != 1 {
t.Error("Expected field value length to be 1")
} else {
if value[0] != "Bar" {
t.Error("Expected first field value length to be Bar")
if value[0] != "Bar\nLine2" {
t.Error("Expected first field value to be Bar")
}
}
}
Expand Down
Loading