Skip to content

Commit 4c7219f

Browse files
authored
feat(experiment): apply similar theme to huh forms (#421)
1 parent 503fa5d commit 4c7219f

4 files changed

Lines changed: 185 additions & 7 deletions

File tree

internal/iostreams/forms.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ func newForm(io *IOStreams, field huh.Field) *huh.Form {
3434
form := huh.NewForm(huh.NewGroup(field))
3535
if io != nil && io.config.WithExperimentOn(experiment.Lipgloss) {
3636
form = form.WithTheme(style.ThemeSlack())
37+
} else {
38+
form = form.WithTheme(style.ThemeSurvey())
3739
}
3840
return form
3941
}
@@ -90,7 +92,7 @@ func buildSelectForm(io *IOStreams, msg string, options []string, cfg SelectProm
9092
key := opt
9193
if cfg.Description != nil {
9294
if desc := style.RemoveEmoji(cfg.Description(opt, len(opts))); desc != "" {
93-
key = opt + " - " + desc
95+
key = style.Bright(opt) + " " + style.Secondary(desc)
9496
}
9597
}
9698
opts = append(opts, huh.NewOption(key, opt))

internal/iostreams/forms_test.go

Lines changed: 94 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,57 @@ func TestSelectForm(t *testing.T) {
195195
assert.Contains(t, view, "First letter")
196196
})
197197

198+
t.Run("descriptions use em-dash separator with lipgloss enabled", func(t *testing.T) {
199+
style.ToggleLipgloss(true)
200+
style.ToggleStyles(true)
201+
t.Cleanup(func() {
202+
style.ToggleLipgloss(false)
203+
style.ToggleStyles(false)
204+
})
205+
206+
fsMock := slackdeps.NewFsMock()
207+
osMock := slackdeps.NewOsMock()
208+
osMock.AddDefaultMocks()
209+
cfg := config.NewConfig(fsMock, osMock)
210+
cfg.ExperimentsFlag = []string{"lipgloss"}
211+
cfg.LoadExperiments(context.Background(), func(_ context.Context, _ string, _ ...any) {})
212+
io := NewIOStreams(cfg, fsMock, osMock)
213+
214+
var selected string
215+
options := []string{"Alpha", "Beta"}
216+
selectCfg := SelectPromptConfig{
217+
Description: func(opt string, _ int) string {
218+
if opt == "Alpha" {
219+
return "First letter"
220+
}
221+
return ""
222+
},
223+
}
224+
f := buildSelectForm(io, "Choose", options, selectCfg, &selected)
225+
f.Update(f.Init())
226+
227+
view := ansi.Strip(f.View())
228+
assert.Contains(t, view, " — First letter")
229+
})
230+
231+
t.Run("descriptions use em-dash separator without lipgloss", func(t *testing.T) {
232+
var selected string
233+
options := []string{"Alpha", "Beta"}
234+
selectCfg := SelectPromptConfig{
235+
Description: func(opt string, _ int) string {
236+
if opt == "Alpha" {
237+
return "First letter"
238+
}
239+
return ""
240+
},
241+
}
242+
f := buildSelectForm(nil, "Choose", options, selectCfg, &selected)
243+
f.Update(f.Init())
244+
245+
view := ansi.Strip(f.View())
246+
assert.Contains(t, view, "Alpha — First letter")
247+
})
248+
198249
t.Run("page size sets field height", func(t *testing.T) {
199250
var selected string
200251
options := []string{"A", "B", "C", "D", "E", "F", "G", "H"}
@@ -283,8 +334,8 @@ func TestMultiSelectForm(t *testing.T) {
283334
m, _ := f.Update(key('x'))
284335
view := ansi.Strip(m.View())
285336

286-
// After toggle, the first item should show as selected (checkmark)
287-
assert.Contains(t, view, "")
337+
// After toggle, the first item should show as selected
338+
assert.Contains(t, view, "[x]")
288339
})
289340

290341
t.Run("submit returns toggled items", func(t *testing.T) {
@@ -364,14 +415,51 @@ func TestFormsUseSlackTheme(t *testing.T) {
364415
})
365416
}
366417

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

373424
view := ansi.Strip(f.View())
374-
// Without lipgloss the Slack theme is not applied, so "[ ]" should not appear
375-
assert.NotContains(t, view, "[ ]")
425+
// ThemeSurvey uses "[ ] " as unselected prefix
426+
assert.Contains(t, view, "[ ]")
427+
})
428+
429+
t.Run("multi-select uses [x] for selected prefix", func(t *testing.T) {
430+
var selected []string
431+
f := buildMultiSelectForm(nil, "Pick", []string{"A", "B"}, &selected)
432+
f.Update(f.Init())
433+
434+
// Toggle first item
435+
m, _ := f.Update(key('x'))
436+
view := ansi.Strip(m.View())
437+
assert.Contains(t, view, "[x]")
438+
})
439+
440+
t.Run("select form renders chevron cursor", func(t *testing.T) {
441+
var selected string
442+
f := buildSelectForm(nil, "Pick", []string{"A", "B"}, SelectPromptConfig{}, &selected)
443+
f.Update(f.Init())
444+
445+
view := ansi.Strip(f.View())
446+
assert.Contains(t, view, style.Chevron()+" A")
447+
})
448+
449+
t.Run("all form builders apply ThemeSurvey without lipgloss", func(t *testing.T) {
450+
var s string
451+
var b bool
452+
var ss []string
453+
forms := []*huh.Form{
454+
buildInputForm(nil, "msg", InputPromptConfig{}, &s),
455+
buildConfirmForm(nil, "msg", &b),
456+
buildSelectForm(nil, "msg", []string{"a"}, SelectPromptConfig{}, &s),
457+
buildPasswordForm(nil, "msg", PasswordPromptConfig{}, &s),
458+
buildMultiSelectForm(nil, "msg", []string{"a"}, &ss),
459+
}
460+
for _, f := range forms {
461+
f.Update(f.Init())
462+
assert.NotEmpty(t, f.View())
463+
}
376464
})
377465
}

internal/style/theme.go

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,54 @@ func Chevron() string {
126126
return "❱"
127127
}
128128

129+
// ThemeSurvey returns a huh Theme that matches the legacy survey prompt styling.
130+
// Applied when experiment.Huh is on but experiment.Lipgloss is off.
131+
func ThemeSurvey() huh.Theme {
132+
return huh.ThemeFunc(themeSurvey)
133+
}
134+
135+
// themeSurvey builds huh styles matching the survey library's appearance.
136+
func themeSurvey(isDark bool) *huh.Styles {
137+
t := huh.ThemeBase(isDark)
138+
139+
ansiBlue := lipgloss.ANSIColor(blue)
140+
ansiGray := lipgloss.ANSIColor(gray)
141+
ansiGreen := lipgloss.ANSIColor(green)
142+
ansiRed := lipgloss.ANSIColor(red)
143+
144+
t.Focused.Title = lipgloss.NewStyle().
145+
Foreground(ansiGray).
146+
Bold(true)
147+
t.Focused.ErrorIndicator = lipgloss.NewStyle().
148+
Foreground(ansiRed).
149+
SetString(" *")
150+
t.Focused.ErrorMessage = lipgloss.NewStyle().
151+
Foreground(ansiRed)
152+
153+
// Select styles
154+
t.Focused.SelectSelector = lipgloss.NewStyle().
155+
Foreground(ansiBlue).
156+
Bold(true).
157+
SetString(Chevron() + " ")
158+
t.Focused.SelectedOption = lipgloss.NewStyle().
159+
Foreground(ansiBlue).
160+
Bold(true)
161+
162+
// Multi-select styles
163+
t.Focused.MultiSelectSelector = lipgloss.NewStyle().
164+
Foreground(ansiBlue).
165+
Bold(true).
166+
SetString(Chevron() + " ")
167+
t.Focused.SelectedPrefix = lipgloss.NewStyle().
168+
Foreground(ansiGreen).
169+
SetString("[x] ")
170+
t.Focused.UnselectedPrefix = lipgloss.NewStyle().
171+
Bold(true).
172+
SetString("[ ] ")
173+
174+
return t
175+
}
176+
129177
// SurveyIcons returns customizations to the appearance of survey prompts.
130178
func SurveyIcons() survey.AskOpt {
131179
if !isStyleEnabled {

internal/style/theme_test.go

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,46 @@ func TestThemeSlack(t *testing.T) {
7676
}
7777
}
7878

79+
func TestThemeSurvey(t *testing.T) {
80+
theme := ThemeSurvey().Theme(false)
81+
tests := map[string]struct {
82+
rendered string
83+
expected []string
84+
unexpected []string
85+
}{
86+
"focused title renders text": {
87+
rendered: theme.Focused.Title.Render("x"),
88+
expected: []string{"x"},
89+
},
90+
"focused error message renders text": {
91+
rendered: theme.Focused.ErrorMessage.Render("err"),
92+
expected: []string{"err"},
93+
},
94+
"focused select selector renders chevron": {
95+
rendered: theme.Focused.SelectSelector.Render(),
96+
expected: []string{Chevron()},
97+
},
98+
"focused multi-select selected prefix has [x]": {
99+
rendered: theme.Focused.SelectedPrefix.Render(),
100+
expected: []string{"[x]"},
101+
},
102+
"focused multi-select unselected prefix has brackets": {
103+
rendered: theme.Focused.UnselectedPrefix.Render(),
104+
expected: []string{"[ ]"},
105+
},
106+
}
107+
for name, tc := range tests {
108+
t.Run(name, func(t *testing.T) {
109+
for _, exp := range tc.expected {
110+
assert.Contains(t, tc.rendered, exp)
111+
}
112+
for _, unexp := range tc.unexpected {
113+
assert.NotContains(t, tc.rendered, unexp)
114+
}
115+
})
116+
}
117+
}
118+
79119
func TestChevron(t *testing.T) {
80120
tests := map[string]struct {
81121
styleEnabled bool

0 commit comments

Comments
 (0)