From 41fdc1869bc60a15652662c4d25c4b80e182e8f9 Mon Sep 17 00:00:00 2001 From: britz Date: Sat, 13 Jun 2026 17:33:07 +0530 Subject: [PATCH] redesign: TUI theme picker with arrow keys, color swatches, and save/cancel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- style_cmd.go | 450 +++++++++++++++++++++++++++------------------------ 1 file changed, 243 insertions(+), 207 deletions(-) diff --git a/style_cmd.go b/style_cmd.go index f4f97d2..a08ca43 100644 --- a/style_cmd.go +++ b/style_cmd.go @@ -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"},