redesign: TUI theme picker with arrow keys, color swatches, and save/cancel

Replace numbered input with a Bubble Tea TUI:
- Arrow keys (↑/↓ or j/k) to browse 11 themes
- Color swatches show actual theme colors below the list
- Enter to save, Esc/q to cancel, r for random palette
- Live preview with color blocks for Text, H1, H1 Bg, H2, Code, Link
This commit is contained in:
britz 2026-06-13 17:33:07 +05:30
commit 41fdc1869b

View file

@ -8,45 +8,47 @@ import (
"path/filepath"
"strings"
"github.com/charmbracelet/glamour"
"github.com/charmbracelet/bubbles/help"
"github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/mitchellh/go-homedir"
"github.com/spf13/cobra"
)
type theme struct {
Name string
Description string
Doc string
H1 string
H1Bg string
H1Bold bool
H2 string
H3 string
H6 string
Code string
CodeBg string
CodeBlock string
Link string
LinkUnder bool
HR string
BqToken string
Name string
Doc string
H1 string
H1Bg string
H1Bold bool
H2 string
H3 string
H6 string
Code string
CodeBg string
CodeBlock string
Link string
LinkUnder bool
HR string
BqToken string
}
var themes = []theme{
{Name: "Catppuccin Mocha", Description: "Dark, warm purple-pink tones", Doc: "252", H1: "228", H1Bg: "63", H1Bold: true, H2: "39", H3: "39", H6: "35", Code: "203", CodeBg: "236", CodeBlock: "244", Link: "30", LinkUnder: true, HR: "240", BqToken: "│ "},
{Name: "Catppuccin Latte", Description: "Light, warm purple-pink tones", Doc: "234", H1: "228", H1Bg: "63", H1Bold: true, H2: "27", H3: "27", H6: "35", Code: "203", CodeBg: "254", CodeBlock: "242", Link: "36", LinkUnder: true, HR: "249", BqToken: "│ "},
{Name: "Nord", Description: "Dark, arctic blue tones", Doc: "252", H1: "228", H1Bg: "67", H1Bold: true, H2: "110", H3: "110", H6: "150", Code: "203", CodeBg: "236", CodeBlock: "244", Link: "110", LinkUnder: true, HR: "240", BqToken: "│ "},
{Name: "Gruvbox Dark", Description: "Dark, retro warm tones", Doc: "223", H1: "228", H1Bg: "88", H1Bold: true, H2: "214", H3: "214", H6: "108", Code: "203", CodeBg: "237", CodeBlock: "244", Link: "109", LinkUnder: true, HR: "240", BqToken: "│ "},
{Name: "Gruvbox Light", Description: "Light, retro warm tones", Doc: "237", H1: "228", H1Bg: "88", H1Bold: true, H2: "214", H3: "214", H6: "108", Code: "203", CodeBg: "254", CodeBlock: "242", Link: "109", LinkUnder: true, HR: "249", BqToken: "│ "},
{Name: "Solarized Dark", Description: "Dark, sepia-toned", Doc: "252", H1: "228", H1Bg: "33", H1Bold: true, H2: "37", H3: "37", H6: "64", Code: "203", CodeBg: "236", CodeBlock: "244", Link: "33", LinkUnder: true, HR: "240", BqToken: "│ "},
{Name: "Solarized Light", Description: "Light, sepia-toned", Doc: "234", H1: "228", H1Bg: "33", H1Bold: true, H2: "37", H3: "37", H6: "64", Code: "203", CodeBg: "254", CodeBlock: "242", Link: "33", LinkUnder: true, HR: "249", BqToken: "│ "},
{Name: "Tokyo Night", Description: "Dark, vibrant neon", Doc: "252", H1: "228", H1Bg: "99", H1Bold: true, H2: "147", H3: "147", H6: "150", Code: "203", CodeBg: "236", CodeBlock: "244", Link: "147", LinkUnder: true, HR: "240", BqToken: "│ "},
{Name: "Dracula", Description: "Dark, purple accents", Doc: "252", H1: "228", H1Bg: "99", H1Bold: true, H2: "212", H3: "212", H6: "150", Code: "203", CodeBg: "236", CodeBlock: "244", Link: "212", LinkUnder: true, HR: "240", BqToken: "│ "},
{Name: "One Dark", Description: "Dark, balanced blue-gray", Doc: "252", H1: "228", H1Bg: "67", H1Bold: true, H2: "75", H3: "75", H6: "150", Code: "203", CodeBg: "236", CodeBlock: "244", Link: "75", LinkUnder: true, HR: "240", BqToken: "│ "},
{Name: "Random", Description: "Surprise me — auto-generated palette", Doc: "252", H1: "228", H1Bg: "63", H1Bold: true, H2: "39", H3: "39", H6: "35", Code: "203", CodeBg: "236", CodeBlock: "244", Link: "30", LinkUnder: true, HR: "240", BqToken: "│ "},
{Name: "Catppuccin Mocha", Doc: "252", H1: "228", H1Bg: "63", H1Bold: true, H2: "39", H3: "39", H6: "35", Code: "203", CodeBg: "236", CodeBlock: "244", Link: "30", LinkUnder: true, HR: "240", BqToken: "│ "},
{Name: "Catppuccin Latte", Doc: "234", H1: "228", H1Bg: "63", H1Bold: true, H2: "27", H3: "27", H6: "35", Code: "203", CodeBg: "254", CodeBlock: "242", Link: "36", LinkUnder: true, HR: "249", BqToken: "│ "},
{Name: "Nord", Doc: "252", H1: "228", H1Bg: "67", H1Bold: true, H2: "110", H3: "110", H6: "150", Code: "203", CodeBg: "236", CodeBlock: "244", Link: "110", LinkUnder: true, HR: "240", BqToken: "│ "},
{Name: "Gruvbox Dark", Doc: "223", H1: "228", H1Bg: "88", H1Bold: true, H2: "214", H3: "214", H6: "108", Code: "203", CodeBg: "237", CodeBlock: "244", Link: "109", LinkUnder: true, HR: "240", BqToken: "│ "},
{Name: "Gruvbox Light", Doc: "237", H1: "228", H1Bg: "88", H1Bold: true, H2: "214", H3: "214", H6: "108", Code: "203", CodeBg: "254", CodeBlock: "242", Link: "109", LinkUnder: true, HR: "249", BqToken: "│ "},
{Name: "Solarized Dark", Doc: "252", H1: "228", H1Bg: "33", H1Bold: true, H2: "37", H3: "37", H6: "64", Code: "203", CodeBg: "236", CodeBlock: "244", Link: "33", LinkUnder: true, HR: "240", BqToken: "│ "},
{Name: "Solarized Light", Doc: "234", H1: "228", H1Bg: "33", H1Bold: true, H2: "37", H3: "37", H6: "64", Code: "203", CodeBg: "254", CodeBlock: "242", Link: "33", LinkUnder: true, HR: "249", BqToken: "│ "},
{Name: "Tokyo Night", Doc: "252", H1: "228", H1Bg: "99", H1Bold: true, H2: "147", H3: "147", H6: "150", Code: "203", CodeBg: "236", CodeBlock: "244", Link: "147", LinkUnder: true, HR: "240", BqToken: "│ "},
{Name: "Dracula", Doc: "252", H1: "228", H1Bg: "99", H1Bold: true, H2: "212", H3: "212", H6: "150", Code: "203", CodeBg: "236", CodeBlock: "244", Link: "212", LinkUnder: true, HR: "240", BqToken: "│ "},
{Name: "One Dark", Doc: "252", H1: "228", H1Bg: "67", H1Bold: true, H2: "75", H3: "75", H6: "150", Code: "203", CodeBg: "236", CodeBlock: "244", Link: "75", LinkUnder: true, HR: "240", BqToken: "│ "},
{Name: "Surprise Me", Doc: "252", H1: "228", H1Bg: "63", H1Bold: true, H2: "39", H3: "39", H6: "35", Code: "203", CodeBg: "236", CodeBlock: "244", Link: "30", LinkUnder: true, HR: "240", BqToken: "│ "},
}
var styleOutputPath string
var outputPath string
var styleCmd = &cobra.Command{
Use: "style",
@ -61,128 +63,221 @@ var styleInitCmd = &cobra.Command{
Use: "init",
Short: "Create a color style from a theme palette",
Long: paragraph(fmt.Sprintf(
"\nChoose from preset color themes or generate a random palette. Preview the result and save it.\n\nThe generated file can be used with %s.",
"\nPick from preset color themes using arrow keys, preview the colors, and save.\nThe generated file can be used with %s.",
keyword("glow -s path/to/style.json"),
)),
Example: paragraph(" glow style init\n glow style init -o ~/mytheme.json"),
Args: cobra.NoArgs,
RunE: func(*cobra.Command, []string) error {
return runStyleInit()
return runStyleInitTUI()
},
}
func init() {
styleInitCmd.Flags().StringVarP(&styleOutputPath, "output", "o", "", "output path for the style JSON (default ~/.config/glow/style.json)")
styleInitCmd.Flags().StringVarP(&outputPath, "output", "o", "", "output path for the style JSON (default ~/.config/glow/style.json)")
styleCmd.AddCommand(styleInitCmd)
}
func runStyleInit() error {
if styleOutputPath == "" {
type keyMap struct {
Up key.Binding
Down key.Binding
Enter key.Binding
Random key.Binding
Quit key.Binding
}
func (k keyMap) ShortHelp() []key.Binding {
return []key.Binding{k.Up, k.Down, k.Enter, k.Random, k.Quit}
}
func (k keyMap) FullHelp() [][]key.Binding {
return [][]key.Binding{{k.Up, k.Down, k.Enter, k.Random, k.Quit}}
}
var keys = keyMap{
Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "up")),
Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "down")),
Enter: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "save")),
Random: key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "random")),
Quit: key.NewBinding(key.WithKeys("esc", "q", "ctrl+c"), key.WithHelp("esc/q", "cancel")),
}
type model struct {
themes []theme
cursor int
result *result
quitting bool
help help.Model
}
type result struct {
styleJSON []byte
themeName string
}
func (m model) Init() tea.Cmd {
return nil
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.help.Width = msg.Width
return m, nil
case tea.KeyMsg:
switch {
case key.Matches(msg, keys.Quit):
m.quitting = true
return m, tea.Quit
case key.Matches(msg, keys.Up):
m.cursor--
if m.cursor < 0 {
m.cursor = len(m.themes) - 1
}
case key.Matches(msg, keys.Down):
m.cursor++
if m.cursor >= len(m.themes) {
m.cursor = 0
}
case key.Matches(msg, keys.Random):
rt := randomTheme()
m.themes = append(m.themes[:len(m.themes)-1], rt)
m.cursor = len(m.themes) - 1
case key.Matches(msg, keys.Enter):
t := m.themes[m.cursor]
data, _ := json.MarshalIndent(buildStyle(t.Doc, t.H1, t.H1Bg, t.H1Bold, t.H2, t.H3, t.H6,
t.Code, t.CodeBg, t.CodeBlock, t.Link, t.LinkUnder, t.HR, t.BqToken), "", " ")
m.result = &result{styleJSON: data, themeName: t.Name}
m.quitting = true
return m, tea.Quit
}
}
return m, nil
}
func (m model) View() string {
if m.quitting {
return ""
}
t := m.themes[m.cursor]
title := lipStyles.title.Render("Pick a theme")
divider := strings.Repeat("─", 70)
var listRows []string
for i, th := range m.themes {
prefix := " "
nameStyle := lipStyles.item
if i == m.cursor {
prefix = lipStyles.cursor.Render("▸")
nameStyle = lipStyles.selected
}
row := fmt.Sprintf("%s %s", prefix, nameStyle.Render(th.Name))
listRows = append(listRows, row)
}
themeList := lipgloss.JoinVertical(lipgloss.Left, listRows...)
swatch := func(label, color string) string {
block := lipgloss.NewStyle().Background(lipgloss.Color(color)).Render(" ")
return lipStyles.swatchLabel.Render(fmt.Sprintf("%s %s", block, label))
}
swatchRow := fmt.Sprintf(
" %s %s %s %s %s %s",
swatch("Text", t.Doc),
swatch("H1", t.H1),
swatch("H1 Bg", t.H1Bg),
swatch("H2", t.H2),
swatch("Code", t.Code),
swatch("Link", t.Link),
)
helpView := m.help.View(keys)
content := fmt.Sprintf(
"%s\n%s\n\n%s\n\n %s\n %s\n\n%s\n",
title,
divider,
themeList,
lipStyles.previewTitle.Render("Colors"),
swatchRow,
helpView,
)
return lipStyles.app.Render(content)
}
func runStyleInitTUI() error {
if outputPath == "" {
home, err := homedir.Dir()
if err != nil {
return fmt.Errorf("unable to determine home directory: %w", err)
}
styleOutputPath = filepath.Join(home, ".config", "glow", "style.json")
outputPath = filepath.Join(home, ".config", "glow", "style.json")
}
for {
fmt.Println()
fmt.Println(" " + keyword("Pick a theme"))
fmt.Println(" " + strings.Repeat("─", 50))
for i, t := range themes {
fmt.Printf(" %2d) %-20s %s\n", i+1, t.Name, t.Description)
}
fmt.Printf(" %2d) %-20s %s\n", 0, "Quit", "exit without saving")
fmt.Print("\n Your choice [1]: ")
var choice int
input := ""
_, _ = fmt.Scanln(&input)
input = strings.TrimSpace(input)
if input == "" {
choice = 1
} else {
_, err := fmt.Sscanf(input, "%d", &choice)
if err != nil || choice < 0 || choice > len(themes) {
fmt.Println(" Invalid choice. Try again.")
continue
}
}
if choice == 0 {
fmt.Println(" " + keyword("Bye!"))
return nil
}
t := themes[choice-1]
if t.Name == "Random" {
t = randomTheme()
}
style := buildStyle(t.Doc, t.H1, t.H1Bg, t.H1Bold, t.H2, t.H3, t.H6,
t.Code, t.CodeBg, t.CodeBlock, t.Link, t.LinkUnder, t.HR, t.BqToken)
data, err := json.MarshalIndent(style, "", " ")
if err != nil {
return fmt.Errorf("unable to marshal style: %w", err)
}
fmt.Println()
fmt.Println(" " + keyword(t.Name))
fmt.Println(" " + strings.Repeat("─", 50))
showPreview(data)
fmt.Println()
fmt.Println(" " + strings.Repeat("─", 50))
fmt.Print(" [S]ave [R]andomize [Q]uit [S]: ")
var action string
_, _ = fmt.Scanln(&action)
action = strings.TrimSpace(strings.ToLower(action))
switch action {
case "", "s", "save":
if err := os.MkdirAll(filepath.Dir(styleOutputPath), 0o700); err != nil {
return fmt.Errorf("unable to create directory: %w", err)
}
if err := os.WriteFile(styleOutputPath, data, 0o600); err != nil {
return fmt.Errorf("unable to write style file: %w", err)
}
fmt.Println()
fmt.Println(" ✓ Style saved to: " + styleOutputPath)
fmt.Println(" Use it with: " + keyword("glow -s "+styleOutputPath))
fmt.Println()
return nil
case "r", "random", "rand":
continue
case "q", "quit":
fmt.Println(" " + keyword("Bye!"))
return nil
default:
continue
}
m := model{
themes: themes,
help: help.New(),
}
p := tea.NewProgram(m, tea.WithAltScreen())
final, err := p.Run()
if err != nil {
return err
}
m = final.(model)
if m.result == nil {
fmt.Println(" Cancelled.")
return nil
}
if err := os.MkdirAll(filepath.Dir(outputPath), 0o700); err != nil {
return fmt.Errorf("unable to create directory: %w", err)
}
if err := os.WriteFile(outputPath, m.result.styleJSON, 0o600); err != nil {
return fmt.Errorf("unable to write style file: %w", err)
}
fmt.Printf(" ✓ %s saved to: %s\n", m.result.themeName, outputPath)
fmt.Printf(" Use it with: %s\n", keyword("glow -s "+outputPath))
return nil
}
func showPreview(data []byte) {
sample := "# Hello World\n\nThis is **bold** and *italic* text. Here is `inline code`.\n\n## Code Block\n\n```go\nfunc hello() {\n\tfmt.Println(\"Hello, World!\")\n}\n```\n\n> A wise blockquote once said...\n\n---\n\n[Link to somewhere](https://example.com)"
type styleSet struct {
app lipgloss.Style
title lipgloss.Style
item lipgloss.Style
selected lipgloss.Style
cursor lipgloss.Style
previewTitle lipgloss.Style
swatchLabel lipgloss.Style
}
r, err := glamour.NewTermRenderer(
glamour.WithStylesFromJSONBytes(data),
glamour.WithWordWrap(60),
)
if err != nil {
fmt.Println(" (preview unavailable)")
return
}
defer r.Close() //nolint:errcheck
var lipStyles styleSet
out, err := r.Render(sample)
if err != nil {
fmt.Println(" (preview unavailable)")
return
func init() {
lipStyles = styleSet{
app: lipgloss.NewStyle().
Padding(1, 2),
title: lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#04B575")).
Padding(0, 0, 1, 0),
item: lipgloss.NewStyle().
Foreground(lipgloss.Color("#A49FA5")),
selected: lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#FFFDF5")),
cursor: lipgloss.NewStyle().
Foreground(lipgloss.Color("#04B575")),
previewTitle: lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#777777")),
swatchLabel: lipgloss.NewStyle().
Foreground(lipgloss.Color("#979797")),
}
fmt.Println(out)
}
func randomTheme() theme {
@ -191,7 +286,7 @@ func randomTheme() theme {
colors[i] = fmt.Sprintf("%d", rand.Intn(256))
}
return theme{
Name: "Random",
Name: "Surprise Me",
Doc: colors[0],
H1: colors[1],
H1Bg: colors[2],
@ -209,105 +304,46 @@ func randomTheme() theme {
}
}
func promptColor(label, defaultColor string, isDark bool) string {
hint := defaultColor
fmt.Printf(" %s [%s]: ", label, hint)
var input string
_, _ = fmt.Scanln(&input)
input = strings.TrimSpace(input)
if input == "" {
return defaultColor
}
return input
}
func buildStyle(docColor, h1Color, h1Bg string, h1Bold bool, h2Color, h3Color, h6Color string,
codeColor, codeBg, codeBlockColor, linkColor string, linkUnderline bool, hrColor, blockQuoteToken string) map[string]interface{} {
return map[string]interface{}{
"document": map[string]interface{}{
"block_prefix": "\n",
"block_suffix": "\n",
"color": docColor,
"margin": 2,
"block_prefix": "\n", "block_suffix": "\n", "color": docColor, "margin": 2,
},
"block_quote": map[string]interface{}{
"indent": 1,
"indent_token": blockQuoteToken,
"indent": 1, "indent_token": blockQuoteToken,
},
"paragraph": map[string]interface{}{},
"list": map[string]interface{}{
"level_indent": 2,
},
"list": map[string]interface{}{"level_indent": 2},
"heading": map[string]interface{}{
"block_suffix": "\n",
"color": h2Color,
"bold": true,
"block_suffix": "\n", "color": h2Color, "bold": true,
},
"h1": map[string]interface{}{
"prefix": " ",
"suffix": " ",
"color": h1Color,
"background_color": h1Bg,
"bold": h1Bold,
},
"h2": map[string]interface{}{
"prefix": "## ",
},
"h3": map[string]interface{}{
"prefix": "### ",
"color": h3Color,
},
"h4": map[string]interface{}{
"prefix": "#### ",
},
"h5": map[string]interface{}{
"prefix": "##### ",
},
"h6": map[string]interface{}{
"prefix": "###### ",
"color": h6Color,
"bold": false,
"prefix": " ", "suffix": " ", "color": h1Color, "background_color": h1Bg, "bold": h1Bold,
},
"h2": map[string]interface{}{"prefix": "## "},
"h3": map[string]interface{}{"prefix": "### ", "color": h3Color},
"h4": map[string]interface{}{"prefix": "#### "},
"h5": map[string]interface{}{"prefix": "##### "},
"h6": map[string]interface{}{"prefix": "###### ", "color": h6Color, "bold": false},
"text": map[string]interface{}{},
"strikethrough": map[string]interface{}{"crossed_out": true},
"emph": map[string]interface{}{"italic": true},
"strong": map[string]interface{}{"bold": true},
"hr": map[string]interface{}{
"color": hrColor,
"format": "\n--------\n",
},
"item": map[string]interface{}{"block_prefix": "• "},
"enumeration": map[string]interface{}{"block_prefix": ". "},
"task": map[string]interface{}{
"ticked": "[✓] ",
"unticked": "[ ] ",
},
"link": map[string]interface{}{
"color": linkColor,
"underline": linkUnderline,
},
"link_text": map[string]interface{}{
"color": "35",
"bold": true,
},
"image": map[string]interface{}{
"color": "212",
"underline": true,
},
"image_text": map[string]interface{}{
"color": "243",
"format": "Image: {{.text}} →",
},
"hr": map[string]interface{}{"color": hrColor, "format": "\n--------\n"},
"item": map[string]interface{}{"block_prefix": "• "},
"enumeration": map[string]interface{}{"block_prefix": ". "},
"task": map[string]interface{}{"ticked": "[✓] ", "unticked": "[ ] "},
"link": map[string]interface{}{"color": linkColor, "underline": linkUnderline},
"link_text": map[string]interface{}{"color": "35", "bold": true},
"image": map[string]interface{}{"color": "212", "underline": true},
"image_text": map[string]interface{}{"color": "243", "format": "Image: {{.text}} →"},
"code": map[string]interface{}{
"prefix": "\u00a0",
"suffix": "\u00a0",
"color": codeColor,
"background_color": codeBg,
"prefix": "\u00a0", "suffix": "\u00a0", "color": codeColor, "background_color": codeBg,
},
"code_block": map[string]interface{}{
"color": codeBlockColor,
"margin": 2,
"color": codeBlockColor, "margin": 2,
"chroma": map[string]interface{}{
"text": map[string]interface{}{"color": "#C4C4C4"},
"error": map[string]interface{}{"color": "#F1F1F1", "background_color": "#F05B5B"},