From 17571ba0aaaf6936cc881ebd0d161e2420707aa8 Mon Sep 17 00:00:00 2001 From: britz Date: Sat, 13 Jun 2026 16:44:23 +0530 Subject: [PATCH 1/5] feat: add 'glow style init' interactive command to create custom color styles Add an interactive CLI command 'glow style init' that walks users through creating a custom glamour JSON stylesheet. Users are prompted for colors for key markdown elements (document text, headings, code, links, etc.) and the command generates a valid stylesheet that can be used with 'glow -s path/to/style.json'. Fixes are included for the generated JSON: ANSI color codes and hex colors are supported, and the output matches the glamour JSON format exactly. --- glow_test.go | 73 +++++++++++++ main.go | 2 +- style_cmd.go | 294 +++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 368 insertions(+), 1 deletion(-) create mode 100644 style_cmd.go diff --git a/glow_test.go b/glow_test.go index 8743be2..621ad1f 100644 --- a/glow_test.go +++ b/glow_test.go @@ -39,3 +39,76 @@ func TestGlowFlags(t *testing.T) { } } } + +func TestBuildStyle(t *testing.T) { + style := buildStyle("252", "228", "63", true, "39", "39", "35", + "203", "236", "244", "30", true, "240", "│ ") + + tests := []struct { + key string + field string + expected interface{} + }{ + {"document", "color", "252"}, + {"document", "margin", 2}, + {"h1", "color", "228"}, + {"h1", "background_color", "63"}, + {"h1", "bold", true}, + {"h2", "prefix", "## "}, + {"code", "color", "203"}, + {"code", "background_color", "236"}, + {"link", "color", "30"}, + {"link", "underline", true}, + {"hr", "color", "240"}, + {"block_quote", "indent_token", "│ "}, + {"strong", "bold", true}, + {"emph", "italic", true}, + {"strikethrough", "crossed_out", true}, + {"task", "ticked", "[✓] "}, + {"task", "unticked", "[ ] "}, + } + + for _, tt := range tests { + section, ok := style[tt.key].(map[string]interface{}) + if !ok { + t.Errorf("missing style section: %s", tt.key) + continue + } + got, ok := section[tt.field] + if !ok { + t.Errorf("missing field %s in section %s", tt.field, tt.key) + continue + } + if got != tt.expected { + t.Errorf("style[%s].%s = %v, want %v", tt.key, tt.field, got, tt.expected) + } + } +} + +func TestBuildStyleCustomColors(t *testing.T) { + style := buildStyle("#FF0000", "#00FF00", "#0000FF", false, "#FF00FF", "#FFFF00", "#00FFFF", + "#FFA500", "#800080", "#A52A2A", "#FFC0CB", false, "#808080", "> ") + + doc := style["document"].(map[string]interface{}) + if doc["color"] != "#FF0000" { + t.Errorf("document.color = %v, want #FF0000", doc["color"]) + } + + h1 := style["h1"].(map[string]interface{}) + if h1["color"] != "#00FF00" { + t.Errorf("h1.color = %v, want #00FF00", h1["color"]) + } + if h1["bold"] != false { + t.Errorf("h1.bold = %v, want false", h1["bold"]) + } + + link := style["link"].(map[string]interface{}) + if link["underline"] != false { + t.Errorf("link.underline = %v, want false", link["underline"]) + } + + bq := style["block_quote"].(map[string]interface{}) + if bq["indent_token"] != "> " { + t.Errorf("block_quote.indent_token = %v, want '> '", bq["indent_token"]) + } +} diff --git a/main.go b/main.go index b31ca15..93ae916 100644 --- a/main.go +++ b/main.go @@ -425,7 +425,7 @@ func init() { viper.SetDefault("width", 0) viper.SetDefault("all", true) - rootCmd.AddCommand(configCmd, manCmd) + rootCmd.AddCommand(configCmd, manCmd, styleCmd) } func tryLoadConfigFromDefaultPlaces() { diff --git a/style_cmd.go b/style_cmd.go new file mode 100644 index 0000000..133477a --- /dev/null +++ b/style_cmd.go @@ -0,0 +1,294 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/mitchellh/go-homedir" + "github.com/spf13/cobra" +) + +var styleOutputPath string + +var styleCmd = &cobra.Command{ + Use: "style", + Short: "Manage glow color styles", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + return cmd.Help() + }, +} + +var styleInitCmd = &cobra.Command{ + Use: "init", + Short: "Create a custom color style interactively", + Long: paragraph(fmt.Sprintf( + "\nWalk through interactive prompts to create a custom %s JSON stylesheet with your preferred colors.\n\nThe generated file can be used with %s.", + keyword("glamour"), + 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() + }, +} + +func init() { + styleInitCmd.Flags().StringVarP(&styleOutputPath, "output", "o", "", "output path for the style JSON (default ~/.config/glow/style.json)") + styleCmd.AddCommand(styleInitCmd) +} + +func runStyleInit() error { + if styleOutputPath == "" { + 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") + } + + fmt.Println() + fmt.Println(" " + keyword("glow style init")) + fmt.Println(" " + strings.Repeat("─", 40)) + fmt.Println(" Press Enter to accept the default value shown in brackets.") + fmt.Println() + + isDark := promptChoose("Base scheme", "1", []string{ + "Dark background", + "Light background", + }) == "1" + + docColor := promptColor("Document text color", "252", isDark) + h1Color := promptColor("H1 text color", "228", isDark) + h1Bg := promptColor("H1 background color", "63", isDark) + h1Bold := promptBool("H1 bold", true) + h2Color := promptColor("H2 text color", "39", isDark) + h3Color := promptColor("H3 text color", "39", isDark) + h6Color := promptColor("H6 text color", "35", isDark) + codeColor := promptColor("Inline code text color", "203", isDark) + codeBg := promptColor("Inline code background color", "236", isDark) + codeBlockColor := promptColor("Code block text color", "244", isDark) + linkColor := promptColor("Link color", "30", isDark) + linkUnderline := promptBool("Underline links", true) + hrColor := promptColor("Horizontal rule color", "240", isDark) + blockQuoteToken := prompt("Blockquote indent token", "│ ") + + style := buildStyle(docColor, h1Color, h1Bg, h1Bold, h2Color, h3Color, h6Color, + codeColor, codeBg, codeBlockColor, linkColor, linkUnderline, hrColor, blockQuoteToken) + + data, err := json.MarshalIndent(style, "", " ") + if err != nil { + return fmt.Errorf("unable to marshal style: %w", err) + } + + 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 +} + +func prompt(label, defaultVal string) string { + fmt.Printf(" %s [%s]: ", label, defaultVal) + var input string + _, _ = fmt.Scanln(&input) + input = strings.TrimSpace(input) + if input == "" { + return defaultVal + } + return input +} + +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 promptBool(label string, defaultVal bool) bool { + defaultStr := "y" + if !defaultVal { + defaultStr = "n" + } + fmt.Printf(" %s? [%s]: ", label, defaultStr) + var input string + _, _ = fmt.Scanln(&input) + input = strings.TrimSpace(strings.ToLower(input)) + if input == "" { + return defaultVal + } + return input == "y" || input == "yes" +} + +func promptChoose(label, defaultVal string, options []string) string { + fmt.Println(" " + label + ":") + for i, opt := range options { + fmt.Printf(" %d) %s\n", i+1, opt) + } + fmt.Printf(" Enter 1-%d [%s]: ", len(options), defaultVal) + var input string + _, _ = fmt.Scanln(&input) + input = strings.TrimSpace(input) + if input == "" { + return defaultVal + } + 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{} { + + style := map[string]interface{}{ + "document": map[string]interface{}{ + "block_prefix": "\n", + "block_suffix": "\n", + "color": docColor, + "margin": 2, + }, + "block_quote": map[string]interface{}{ + "indent": 1, + "indent_token": blockQuoteToken, + }, + "paragraph": map[string]interface{}{}, + "list": map[string]interface{}{ + "level_indent": 2, + }, + "heading": map[string]interface{}{ + "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, + }, + "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}} →", + }, + "code": map[string]interface{}{ + "prefix": "\u00a0", + "suffix": "\u00a0", + "color": codeColor, + "background_color": codeBg, + }, + "code_block": map[string]interface{}{ + "color": codeBlockColor, + "margin": 2, + "chroma": map[string]interface{}{ + "text": map[string]interface{}{"color": "#C4C4C4"}, + "error": map[string]interface{}{"color": "#F1F1F1", "background_color": "#F05B5B"}, + "comment": map[string]interface{}{"color": "#676767"}, + "comment_preproc": map[string]interface{}{"color": "#FF875F"}, + "keyword": map[string]interface{}{"color": "#00AAFF"}, + "keyword_reserved": map[string]interface{}{"color": "#FF5FD2"}, + "keyword_namespace": map[string]interface{}{"color": "#FF5F87"}, + "keyword_type": map[string]interface{}{"color": "#6E6ED8"}, + "operator": map[string]interface{}{"color": "#EF8080"}, + "punctuation": map[string]interface{}{"color": "#E8E8A8"}, + "name": map[string]interface{}{"color": "#C4C4C4"}, + "name_builtin": map[string]interface{}{"color": "#FF8EC7"}, + "name_tag": map[string]interface{}{"color": "#B083EA"}, + "name_attribute": map[string]interface{}{"color": "#7A7AE6"}, + "name_class": map[string]interface{}{"color": "#F1F1F1", "underline": true, "bold": true}, + "name_decorator": map[string]interface{}{"color": "#FFFF87"}, + "name_function": map[string]interface{}{"color": "#00D787"}, + "literal_number": map[string]interface{}{"color": "#6EEFC0"}, + "literal_string": map[string]interface{}{"color": "#C69669"}, + "literal_string_escape": map[string]interface{}{"color": "#AFFFD7"}, + "generic_deleted": map[string]interface{}{"color": "#FD5B5B"}, + "generic_emph": map[string]interface{}{"italic": true}, + "generic_inserted": map[string]interface{}{"color": "#00D787"}, + "generic_strong": map[string]interface{}{"bold": true}, + "generic_subheading": map[string]interface{}{"color": "#777777"}, + "background": map[string]interface{}{"background_color": "#373737"}, + }, + }, + "table": map[string]interface{}{}, + "definition_list": map[string]interface{}{}, + "definition_term": map[string]interface{}{}, + "definition_description": map[string]interface{}{ + "block_prefix": "\n🠶 ", + }, + "html_block": map[string]interface{}{}, + "html_span": map[string]interface{}{}, + } + + return style +} From 028b0f7bdb66050fd9b0940650b130b68eb7f7da Mon Sep 17 00:00:00 2001 From: britz Date: Sat, 13 Jun 2026 16:59:02 +0530 Subject: [PATCH 2/5] redesign: simpler 'glow style init' with 11 preset themes and live preview Replace individual per-element prompts with a simple theme picker: - 10 curated color themes (Catppuccin, Nord, Gruvbox, Solarized, etc.) - 1 Random option for auto-generated color palettes - Live markdown preview rendered with glamour showing h1, code, links, etc. - Save/Re-randomize/Quit workflow - No new dependencies needed --- style_cmd.go | 275 ++++++++++++++++++++++++++++++--------------------- 1 file changed, 164 insertions(+), 111 deletions(-) diff --git a/style_cmd.go b/style_cmd.go index 133477a..f4f97d2 100644 --- a/style_cmd.go +++ b/style_cmd.go @@ -3,14 +3,49 @@ package main import ( "encoding/json" "fmt" + "math/rand" "os" "path/filepath" "strings" + "github.com/charmbracelet/glamour" "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 +} + +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: "│ "}, +} + var styleOutputPath string var styleCmd = &cobra.Command{ @@ -24,10 +59,9 @@ var styleCmd = &cobra.Command{ var styleInitCmd = &cobra.Command{ Use: "init", - Short: "Create a custom color style interactively", + Short: "Create a color style from a theme palette", Long: paragraph(fmt.Sprintf( - "\nWalk through interactive prompts to create a custom %s JSON stylesheet with your preferred colors.\n\nThe generated file can be used with %s.", - keyword("glamour"), + "\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.", keyword("glow -s path/to/style.json"), )), Example: paragraph(" glow style init\n glow style init -o ~/mytheme.json"), @@ -51,65 +85,128 @@ func runStyleInit() error { styleOutputPath = filepath.Join(home, ".config", "glow", "style.json") } - fmt.Println() - fmt.Println(" " + keyword("glow style init")) - fmt.Println(" " + strings.Repeat("─", 40)) - fmt.Println(" Press Enter to accept the default value shown in brackets.") - fmt.Println() + 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]: ") - isDark := promptChoose("Base scheme", "1", []string{ - "Dark background", - "Light background", - }) == "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 + } - docColor := promptColor("Document text color", "252", isDark) - h1Color := promptColor("H1 text color", "228", isDark) - h1Bg := promptColor("H1 background color", "63", isDark) - h1Bold := promptBool("H1 bold", true) - h2Color := promptColor("H2 text color", "39", isDark) - h3Color := promptColor("H3 text color", "39", isDark) - h6Color := promptColor("H6 text color", "35", isDark) - codeColor := promptColor("Inline code text color", "203", isDark) - codeBg := promptColor("Inline code background color", "236", isDark) - codeBlockColor := promptColor("Code block text color", "244", isDark) - linkColor := promptColor("Link color", "30", isDark) - linkUnderline := promptBool("Underline links", true) - hrColor := promptColor("Horizontal rule color", "240", isDark) - blockQuoteToken := prompt("Blockquote indent token", "│ ") + t := themes[choice-1] + if t.Name == "Random" { + t = randomTheme() + } - style := buildStyle(docColor, h1Color, h1Bg, h1Bold, h2Color, h3Color, h6Color, - codeColor, codeBg, codeBlockColor, linkColor, linkUnderline, hrColor, blockQuoteToken) + 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) + 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 + } } - - 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 } -func prompt(label, defaultVal string) string { - fmt.Printf(" %s [%s]: ", label, defaultVal) - var input string - _, _ = fmt.Scanln(&input) - input = strings.TrimSpace(input) - if input == "" { - return defaultVal +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)" + + r, err := glamour.NewTermRenderer( + glamour.WithStylesFromJSONBytes(data), + glamour.WithWordWrap(60), + ) + if err != nil { + fmt.Println(" (preview unavailable)") + return + } + defer r.Close() //nolint:errcheck + + out, err := r.Render(sample) + if err != nil { + fmt.Println(" (preview unavailable)") + return + } + fmt.Println(out) +} + +func randomTheme() theme { + colors := make([]string, 10) + for i := range colors { + colors[i] = fmt.Sprintf("%d", rand.Intn(256)) + } + return theme{ + Name: "Random", + Doc: colors[0], + H1: colors[1], + H1Bg: colors[2], + H1Bold: true, + H2: colors[3], + H3: colors[4], + H6: colors[5], + Code: colors[6], + CodeBg: colors[7], + CodeBlock: colors[8], + Link: colors[9], + LinkUnder: true, + HR: colors[0], + BqToken: "│ ", } - return input } func promptColor(label, defaultColor string, isDark bool) string { @@ -124,40 +221,10 @@ func promptColor(label, defaultColor string, isDark bool) string { return input } -func promptBool(label string, defaultVal bool) bool { - defaultStr := "y" - if !defaultVal { - defaultStr = "n" - } - fmt.Printf(" %s? [%s]: ", label, defaultStr) - var input string - _, _ = fmt.Scanln(&input) - input = strings.TrimSpace(strings.ToLower(input)) - if input == "" { - return defaultVal - } - return input == "y" || input == "yes" -} - -func promptChoose(label, defaultVal string, options []string) string { - fmt.Println(" " + label + ":") - for i, opt := range options { - fmt.Printf(" %d) %s\n", i+1, opt) - } - fmt.Printf(" Enter 1-%d [%s]: ", len(options), defaultVal) - var input string - _, _ = fmt.Scanln(&input) - input = strings.TrimSpace(input) - if input == "" { - return defaultVal - } - 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{} { - style := map[string]interface{}{ + return map[string]interface{}{ "document": map[string]interface{}{ "block_prefix": "\n", "block_suffix": "\n", @@ -202,26 +269,16 @@ func buildStyle(docColor, h1Color, h1Bg string, h1Bold bool, h2Color, h3Color, h "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, - }, + "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": ". ", - }, + "item": map[string]interface{}{"block_prefix": "• "}, + "enumeration": map[string]interface{}{"block_prefix": ". "}, "task": map[string]interface{}{ "ticked": "[✓] ", "unticked": "[ ] ", @@ -280,15 +337,11 @@ func buildStyle(docColor, h1Color, h1Bg string, h1Bold bool, h2Color, h3Color, h "background": map[string]interface{}{"background_color": "#373737"}, }, }, - "table": map[string]interface{}{}, - "definition_list": map[string]interface{}{}, - "definition_term": map[string]interface{}{}, - "definition_description": map[string]interface{}{ - "block_prefix": "\n🠶 ", - }, - "html_block": map[string]interface{}{}, - "html_span": map[string]interface{}{}, + "table": map[string]interface{}{}, + "definition_list": map[string]interface{}{}, + "definition_term": map[string]interface{}{}, + "definition_description": map[string]interface{}{"block_prefix": "\n🠶 "}, + "html_block": map[string]interface{}{}, + "html_span": map[string]interface{}{}, } - - return style } From 41fdc1869bc60a15652662c4d25c4b80e182e8f9 Mon Sep 17 00:00:00 2001 From: britz Date: Sat, 13 Jun 2026 17:33:07 +0530 Subject: [PATCH 3/5] 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"}, From 07a89d7356a7fd3142f705e4c876a87a9de0bc12 Mon Sep 17 00:00:00 2001 From: britz Date: Sat, 13 Jun 2026 17:36:16 +0530 Subject: [PATCH 4/5] feat: live rendered markdown preview in style init TUI Show a rendered markdown snippet using glamour with the selected theme's colors. Preview updates in real-time as user navigates themes with arrow keys. Removes redundant color swatches in favor of actual rendered output. --- style_cmd.go | 55 ++++++++++++++++++++++++++++++++-------------------- 1 file changed, 34 insertions(+), 21 deletions(-) diff --git a/style_cmd.go b/style_cmd.go index a08ca43..3dd34f2 100644 --- a/style_cmd.go +++ b/style_cmd.go @@ -11,6 +11,7 @@ import ( "github.com/charmbracelet/bubbles/help" "github.com/charmbracelet/bubbles/key" tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/glamour" "github.com/charmbracelet/lipgloss" "github.com/mitchellh/go-homedir" "github.com/spf13/cobra" @@ -108,8 +109,11 @@ type model struct { result *result quitting bool help help.Model + preview string } +const sampleMD = "# Hello World\n\nThis is **bold** and *italic* text. Here is `inline code`.\n\n> A blockquote with style\n\n```\n$ glow README.md\n```\n\n[Link to somewhere](https://example.com)" + type result struct { styleJSON []byte themeName string @@ -119,6 +123,27 @@ func (m model) Init() tea.Cmd { return nil } +func (m model) renderPreview(t theme) string { + data, err := 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), "", " ") + if err != nil { + return " (preview unavailable)" + } + r, err := glamour.NewTermRenderer( + glamour.WithStylesFromJSONBytes(data), + glamour.WithWordWrap(60), + ) + if err != nil { + return " (preview unavailable)" + } + defer r.Close() + out, err := r.Render(sampleMD) + if err != nil { + return " (preview unavailable)" + } + return strings.TrimRight(out, "\n") +} + func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: @@ -134,15 +159,18 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.cursor < 0 { m.cursor = len(m.themes) - 1 } + m.preview = m.renderPreview(m.themes[m.cursor]) case key.Matches(msg, keys.Down): m.cursor++ if m.cursor >= len(m.themes) { m.cursor = 0 } + m.preview = m.renderPreview(m.themes[m.cursor]) case key.Matches(msg, keys.Random): rt := randomTheme() m.themes = append(m.themes[:len(m.themes)-1], rt) m.cursor = len(m.themes) - 1 + m.preview = m.renderPreview(m.themes[m.cursor]) 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, @@ -160,8 +188,6 @@ func (m model) View() string { return "" } - t := m.themes[m.cursor] - title := lipStyles.title.Render("Pick a theme") divider := strings.Repeat("─", 70) @@ -178,29 +204,15 @@ func (m model) View() string { } 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", + "%s\n%s\n\n%s\n\n %s\n%s\n\n%s\n", title, divider, themeList, - lipStyles.previewTitle.Render("Colors"), - swatchRow, + lipStyles.previewTitle.Render("Preview"), + m.preview, helpView, ) @@ -217,8 +229,9 @@ func runStyleInitTUI() error { } m := model{ - themes: themes, - help: help.New(), + themes: themes, + help: help.New(), + preview: (&model{}).renderPreview(themes[0]), } p := tea.NewProgram(m, tea.WithAltScreen()) From 570d84d82f2dfe38f71d095972ea99e903917b28 Mon Sep 17 00:00:00 2001 From: britz Date: Sat, 13 Jun 2026 17:38:58 +0530 Subject: [PATCH 5/5] fix: visible color palette and sample text preview with explicit fg+bg Replace glamour-rendered markdown preview with direct lipgloss-rendered color palette blocks and sample text that always set both foreground and background colors. This ensures visibility regardless of terminal theme. Palette shows colored blocks with ANSI codes for Text, H1, Code, Link, HR, H1Bg. Sample section shows styled heading, subtitle, code, and link. --- style_cmd.go | 187 +++++++++++++++++---------------------------------- 1 file changed, 63 insertions(+), 124 deletions(-) diff --git a/style_cmd.go b/style_cmd.go index 3dd34f2..aba459f 100644 --- a/style_cmd.go +++ b/style_cmd.go @@ -11,7 +11,6 @@ import ( "github.com/charmbracelet/bubbles/help" "github.com/charmbracelet/bubbles/key" tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/glamour" "github.com/charmbracelet/lipgloss" "github.com/mitchellh/go-homedir" "github.com/spf13/cobra" @@ -80,11 +79,7 @@ func init() { } type keyMap struct { - Up key.Binding - Down key.Binding - Enter key.Binding - Random key.Binding - Quit key.Binding + Up, Down, Enter, Random, Quit key.Binding } func (k keyMap) ShortHelp() []key.Binding { @@ -109,40 +104,14 @@ type model struct { result *result quitting bool help help.Model - preview string } -const sampleMD = "# Hello World\n\nThis is **bold** and *italic* text. Here is `inline code`.\n\n> A blockquote with style\n\n```\n$ glow README.md\n```\n\n[Link to somewhere](https://example.com)" - type result struct { styleJSON []byte themeName string } -func (m model) Init() tea.Cmd { - return nil -} - -func (m model) renderPreview(t theme) string { - data, err := 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), "", " ") - if err != nil { - return " (preview unavailable)" - } - r, err := glamour.NewTermRenderer( - glamour.WithStylesFromJSONBytes(data), - glamour.WithWordWrap(60), - ) - if err != nil { - return " (preview unavailable)" - } - defer r.Close() - out, err := r.Render(sampleMD) - if err != nil { - return " (preview unavailable)" - } - return strings.TrimRight(out, "\n") -} +func (m model) Init() tea.Cmd { return nil } func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { @@ -159,18 +128,15 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.cursor < 0 { m.cursor = len(m.themes) - 1 } - m.preview = m.renderPreview(m.themes[m.cursor]) case key.Matches(msg, keys.Down): m.cursor++ if m.cursor >= len(m.themes) { m.cursor = 0 } - m.preview = m.renderPreview(m.themes[m.cursor]) case key.Matches(msg, keys.Random): rt := randomTheme() m.themes = append(m.themes[:len(m.themes)-1], rt) m.cursor = len(m.themes) - 1 - m.preview = m.renderPreview(m.themes[m.cursor]) 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, @@ -187,36 +153,59 @@ func (m model) View() string { if m.quitting { return "" } + t := m.themes[m.cursor] + div := strings.Repeat("─", 70) - title := lipStyles.title.Render("Pick a theme") - divider := strings.Repeat("─", 70) - - var listRows []string + listRows := make([]string, len(m.themes)) for i, th := range m.themes { prefix := " " - nameStyle := lipStyles.item + var row string if i == m.cursor { - prefix = lipStyles.cursor.Render("▸") - nameStyle = lipStyles.selected + prefix = cBg("#04B575").Render("▸") + sel := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FFFDF5")) + row = sel.Render(th.Name) + } else { + row = cFg("#A49FA5").Render(th.Name) } - row := fmt.Sprintf("%s %s", prefix, nameStyle.Render(th.Name)) - listRows = append(listRows, row) + listRows[i] = fmt.Sprintf("%s %s", prefix, row) } - themeList := lipgloss.JoinVertical(lipgloss.Left, listRows...) - helpView := m.help.View(keys) + square := func(color string) string { + return cBg(color).Render(" ") + } - content := fmt.Sprintf( - "%s\n%s\n\n%s\n\n %s\n%s\n\n%s\n", - title, - divider, - themeList, - lipStyles.previewTitle.Render("Preview"), - m.preview, - helpView, + palette := fmt.Sprintf( + " %s %-6s %-4s %s %-6s %-4s %s %-6s %-4s\n %s %-6s %-4s %s %-6s %-4s %s %-6s %-4s", + square(t.Doc), "Text", t.Doc, + square(t.H1), "H1", t.H1, + square(t.Code), "Code", t.Code, + square(t.Link), "Link", t.Link, + square(t.HR), "HR", t.HR, + square(t.H1Bg), "H1Bg", t.H1Bg, ) - return lipStyles.app.Render(content) + sample := fmt.Sprintf( + " %s\n %s\n %s\n %s %s", + cBoth(t.H1, t.H1Bg).Render(" Heading 1 (H1) "), + cFg(t.H2).Render(" ## Heading 2 (H2)"), + cFg(t.Doc).Render(" Normal text with ")+cBoth(t.Code, t.CodeBg).Render(" inline code ")+cFg(t.Doc).Render(" here."), + cFg(t.Link).Render(" > Link hover preview"), + cFg(t.HR).Render(" ─────────────"), + ) + + content := fmt.Sprintf( + "%s\n%s\n\n%s\n\n %s\n%s\n\n %s\n%s\n\n%s\n", + lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#04B575")).Render("Pick a theme"), + div, + strings.Join(listRows, "\n"), + lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#777777")).Render("Palette"), + palette, + lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#777777")).Render("Sample"), + sample, + m.help.View(keys), + ) + + return lipgloss.NewStyle().Padding(1, 2).Render(content) } func runStyleInitTUI() error { @@ -228,12 +217,7 @@ func runStyleInitTUI() error { outputPath = filepath.Join(home, ".config", "glow", "style.json") } - m := model{ - themes: themes, - help: help.New(), - preview: (&model{}).renderPreview(themes[0]), - } - + m := model{themes: themes, help: help.New()} p := tea.NewProgram(m, tea.WithAltScreen()) final, err := p.Run() if err != nil { @@ -258,39 +242,16 @@ func runStyleInitTUI() error { return nil } -type styleSet struct { - app lipgloss.Style - title lipgloss.Style - item lipgloss.Style - selected lipgloss.Style - cursor lipgloss.Style - previewTitle lipgloss.Style - swatchLabel lipgloss.Style +func cFg(color string) lipgloss.Style { + return lipgloss.NewStyle().Foreground(lipgloss.Color(color)) } -var lipStyles styleSet +func cBg(color string) lipgloss.Style { + return lipgloss.NewStyle().Background(lipgloss.Color(color)) +} -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")), - } +func cBoth(fg, bg string) lipgloss.Style { + return lipgloss.NewStyle().Foreground(lipgloss.Color(fg)).Background(lipgloss.Color(bg)) } func randomTheme() theme { @@ -299,21 +260,9 @@ func randomTheme() theme { colors[i] = fmt.Sprintf("%d", rand.Intn(256)) } return theme{ - Name: "Surprise Me", - Doc: colors[0], - H1: colors[1], - H1Bg: colors[2], - H1Bold: true, - H2: colors[3], - H3: colors[4], - H6: colors[5], - Code: colors[6], - CodeBg: colors[7], - CodeBlock: colors[8], - Link: colors[9], - LinkUnder: true, - HR: colors[0], - BqToken: "│ ", + Name: "Surprise Me", Doc: colors[0], H1: colors[1], H1Bg: colors[2], H1Bold: true, + H2: colors[3], H3: colors[4], H6: colors[5], Code: colors[6], CodeBg: colors[7], + CodeBlock: colors[8], Link: colors[9], LinkUnder: true, HR: colors[0], BqToken: "│ ", } } @@ -321,20 +270,12 @@ func buildStyle(docColor, h1Color, h1Bg string, h1Bold bool, h2Color, h3Color, h 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_quote": map[string]interface{}{ - "indent": 1, "indent_token": blockQuoteToken, - }, - "paragraph": map[string]interface{}{}, - "list": map[string]interface{}{"level_indent": 2}, - "heading": map[string]interface{}{ - "block_suffix": "\n", "color": h2Color, "bold": true, - }, - "h1": map[string]interface{}{ - "prefix": " ", "suffix": " ", "color": h1Color, "background_color": h1Bg, "bold": h1Bold, - }, + "document": map[string]interface{}{"block_prefix": "\n", "block_suffix": "\n", "color": docColor, "margin": 2}, + "block_quote": map[string]interface{}{"indent": 1, "indent_token": blockQuoteToken}, + "paragraph": map[string]interface{}{}, + "list": map[string]interface{}{"level_indent": 2}, + "heading": map[string]interface{}{"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": "#### "}, @@ -352,9 +293,7 @@ func buildStyle(docColor, h1Color, h1Bg string, h1Bold bool, h2Color, h3Color, h "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, - }, + "code": map[string]interface{}{"prefix": "\u00a0", "suffix": "\u00a0", "color": codeColor, "background_color": codeBg}, "code_block": map[string]interface{}{ "color": codeBlockColor, "margin": 2, "chroma": map[string]interface{}{