Skip to content
Draft
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
4 changes: 3 additions & 1 deletion internal/iostreams/forms.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ func newForm(io *IOStreams, field huh.Field) *huh.Form {
form := huh.NewForm(huh.NewGroup(field))
if io != nil && io.config.WithExperimentOn(experiment.Lipgloss) {
form = form.WithTheme(style.ThemeSlack())
} else {
form = form.WithTheme(style.ThemeSurvey())
}
return form
}
Expand Down Expand Up @@ -90,7 +92,7 @@ func buildSelectForm(io *IOStreams, msg string, options []string, cfg SelectProm
key := opt
if cfg.Description != nil {
if desc := style.RemoveEmoji(cfg.Description(opt, len(opts))); desc != "" {
key = opt + " - " + desc
key = style.Bright(opt) + " " + style.Secondary(desc)
}
}
opts = append(opts, huh.NewOption(key, opt))
Expand Down
96 changes: 92 additions & 4 deletions internal/iostreams/forms_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,57 @@ func TestSelectForm(t *testing.T) {
assert.Contains(t, view, "First letter")
})

t.Run("descriptions use em-dash separator with lipgloss enabled", func(t *testing.T) {
style.ToggleLipgloss(true)
style.ToggleStyles(true)
t.Cleanup(func() {
style.ToggleLipgloss(false)
style.ToggleStyles(false)
})

fsMock := slackdeps.NewFsMock()
osMock := slackdeps.NewOsMock()
osMock.AddDefaultMocks()
cfg := config.NewConfig(fsMock, osMock)
cfg.ExperimentsFlag = []string{"lipgloss"}
cfg.LoadExperiments(context.Background(), func(_ context.Context, _ string, _ ...any) {})
io := NewIOStreams(cfg, fsMock, osMock)

var selected string
options := []string{"Alpha", "Beta"}
selectCfg := SelectPromptConfig{
Description: func(opt string, _ int) string {
if opt == "Alpha" {
return "First letter"
}
return ""
},
}
f := buildSelectForm(io, "Choose", options, selectCfg, &selected)
f.Update(f.Init())

view := ansi.Strip(f.View())
assert.Contains(t, view, " — First letter")
})

t.Run("descriptions use em-dash separator without lipgloss", func(t *testing.T) {
var selected string
options := []string{"Alpha", "Beta"}
selectCfg := SelectPromptConfig{
Description: func(opt string, _ int) string {
if opt == "Alpha" {
return "First letter"
}
return ""
},
}
f := buildSelectForm(nil, "Choose", options, selectCfg, &selected)
f.Update(f.Init())

view := ansi.Strip(f.View())
assert.Contains(t, view, "Alpha — First letter")
})

t.Run("page size sets field height", func(t *testing.T) {
var selected string
options := []string{"A", "B", "C", "D", "E", "F", "G", "H"}
Expand Down Expand Up @@ -364,14 +415,51 @@ func TestFormsUseSlackTheme(t *testing.T) {
})
}

func TestFormsWithoutLipgloss(t *testing.T) {
t.Run("multi-select uses default prefix without lipgloss", func(t *testing.T) {
func TestFormsUseSurveyTheme(t *testing.T) {
t.Run("multi-select uses survey prefix without lipgloss", func(t *testing.T) {
var selected []string
f := buildMultiSelectForm(nil, "Pick", []string{"A", "B"}, &selected)
f.Update(f.Init())

view := ansi.Strip(f.View())
// Without lipgloss the Slack theme is not applied, so "[ ]" should not appear
assert.NotContains(t, view, "[ ]")
// ThemeSurvey uses "[ ] " as unselected prefix
assert.Contains(t, view, "[ ]")
})

t.Run("multi-select uses [x] for selected prefix", func(t *testing.T) {
var selected []string
f := buildMultiSelectForm(nil, "Pick", []string{"A", "B"}, &selected)
f.Update(f.Init())

// Toggle first item
m, _ := f.Update(key('x'))
view := ansi.Strip(m.View())
assert.Contains(t, view, "[x]")
})

t.Run("select form renders chevron cursor", func(t *testing.T) {
var selected string
f := buildSelectForm(nil, "Pick", []string{"A", "B"}, SelectPromptConfig{}, &selected)
f.Update(f.Init())

view := ansi.Strip(f.View())
assert.Contains(t, view, style.Chevron()+" A")
})

t.Run("all form builders apply ThemeSurvey without lipgloss", func(t *testing.T) {
var s string
var b bool
var ss []string
forms := []*huh.Form{
buildInputForm(nil, "msg", InputPromptConfig{}, &s),
buildConfirmForm(nil, "msg", &b),
buildSelectForm(nil, "msg", []string{"a"}, SelectPromptConfig{}, &s),
buildPasswordForm(nil, "msg", PasswordPromptConfig{}, &s),
buildMultiSelectForm(nil, "msg", []string{"a"}, &ss),
}
for _, f := range forms {
f.Update(f.Init())
assert.NotEmpty(t, f.View())
}
})
}
48 changes: 48 additions & 0 deletions internal/style/theme.go
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,54 @@ func Chevron() string {
return "❱"
}

// ThemeSurvey returns a huh Theme that matches the legacy survey prompt styling.
// Applied when experiment.Huh is on but experiment.Lipgloss is off.
func ThemeSurvey() huh.Theme {
return huh.ThemeFunc(themeSurvey)
}

// themeSurvey builds huh styles matching the survey library's appearance.
func themeSurvey(isDark bool) *huh.Styles {
t := huh.ThemeBase(isDark)

ansiBlue := lipgloss.ANSIColor(blue)
ansiGray := lipgloss.ANSIColor(gray)
ansiGreen := lipgloss.ANSIColor(green)
ansiRed := lipgloss.ANSIColor(red)

t.Focused.Title = lipgloss.NewStyle().
Foreground(ansiGray).
Bold(true)
t.Focused.ErrorIndicator = lipgloss.NewStyle().
Foreground(ansiRed).
SetString(" *")
t.Focused.ErrorMessage = lipgloss.NewStyle().
Foreground(ansiRed)

// Select styles
t.Focused.SelectSelector = lipgloss.NewStyle().
Foreground(ansiBlue).
Bold(true).
SetString(Chevron() + " ")
t.Focused.SelectedOption = lipgloss.NewStyle().
Foreground(ansiBlue).
Bold(true)

// Multi-select styles
t.Focused.MultiSelectSelector = lipgloss.NewStyle().
Foreground(ansiBlue).
Bold(true).
SetString(Chevron() + " ")
t.Focused.SelectedPrefix = lipgloss.NewStyle().
Foreground(ansiGreen).
SetString("[x] ")
t.Focused.UnselectedPrefix = lipgloss.NewStyle().
Bold(true).
SetString("[ ] ")

return t
}

// SurveyIcons returns customizations to the appearance of survey prompts.
func SurveyIcons() survey.AskOpt {
if !isStyleEnabled {
Expand Down
40 changes: 40 additions & 0 deletions internal/style/theme_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,46 @@ func TestThemeSlack(t *testing.T) {
}
}

func TestThemeSurvey(t *testing.T) {
theme := ThemeSurvey().Theme(false)
tests := map[string]struct {
rendered string
expected []string
unexpected []string
}{
"focused title renders text": {
rendered: theme.Focused.Title.Render("x"),
expected: []string{"x"},
},
"focused error message renders text": {
rendered: theme.Focused.ErrorMessage.Render("err"),
expected: []string{"err"},
},
"focused select selector renders chevron": {
rendered: theme.Focused.SelectSelector.Render(),
expected: []string{Chevron()},
},
"focused multi-select selected prefix has [x]": {
rendered: theme.Focused.SelectedPrefix.Render(),
expected: []string{"[x]"},
},
"focused multi-select unselected prefix has brackets": {
rendered: theme.Focused.UnselectedPrefix.Render(),
expected: []string{"[ ]"},
},
}
for name, tc := range tests {
t.Run(name, func(t *testing.T) {
for _, exp := range tc.expected {
assert.Contains(t, tc.rendered, exp)
}
for _, unexp := range tc.unexpected {
assert.NotContains(t, tc.rendered, unexp)
}
})
}
}

func TestChevron(t *testing.T) {
tests := map[string]struct {
styleEnabled bool
Expand Down
Loading