diff --git a/internal/iostreams/forms.go b/internal/iostreams/forms.go index d5e43f6c..889a4899 100644 --- a/internal/iostreams/forms.go +++ b/internal/iostreams/forms.go @@ -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 } @@ -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)) diff --git a/internal/iostreams/forms_test.go b/internal/iostreams/forms_test.go index f0ba9144..c7b3d548 100644 --- a/internal/iostreams/forms_test.go +++ b/internal/iostreams/forms_test.go @@ -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"} @@ -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()) + } }) } diff --git a/internal/style/theme.go b/internal/style/theme.go index 100925dc..02b5430a 100644 --- a/internal/style/theme.go +++ b/internal/style/theme.go @@ -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 { diff --git a/internal/style/theme_test.go b/internal/style/theme_test.go index 142ee395..6650a2f1 100644 --- a/internal/style/theme_test.go +++ b/internal/style/theme_test.go @@ -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