Switch streaming markdown formatter to Catppuccin Macchiato color scheme

Replace Dracula-era hardcoded ANSI colors with named constants from the
Catppuccin Macchiato palette. All semantic roles now use 24-bit RGB values:

  Headers: Mauve (H1), Blue (H2), Lavender (H3), Teal (H4), Subtext1 (H5+)
  Bold: Sky (#91d7e3)
  Italic: Sapphire (#7dc4e4)
  Inline code: Peach (#f5a97f)
  Links: Green (#a6da95) underlined
  HR/labels: Overlay1 (#8087a2)

Also switches syntect code highlighting theme from base16-ocean.dark to
base16-mocha.dark for better palette consistency.
This commit is contained in:
Dhanji R. Prasanna
2026-03-03 11:04:12 +11:00
parent 98ca094be7
commit d5a5f832f2
2 changed files with 70 additions and 41 deletions

View File

@@ -22,6 +22,35 @@ use termimad::MadSkin;
static SYNTAX_SET: Lazy<SyntaxSet> = Lazy::new(SyntaxSet::load_defaults_newlines); static SYNTAX_SET: Lazy<SyntaxSet> = Lazy::new(SyntaxSet::load_defaults_newlines);
static THEME_SET: Lazy<ThemeSet> = Lazy::new(ThemeSet::load_defaults); static THEME_SET: Lazy<ThemeSet> = Lazy::new(ThemeSet::load_defaults);
// ── Catppuccin Macchiato palette ──────────────────────────────────────────────
// https://github.com/catppuccin/catppuccin (Macchiato variant)
//
// Each constant is an ANSI 24-bit color prefix: \x1b[38;2;R;G;Bm
// Combine with style modifiers (1=bold, 3=italic, 4=underline, 9=strike) as needed.
/// Mauve #c6a0f6 — H1 headers
const MAUVE: &str = "\x1b[38;2;198;160;246m";
/// Blue #8aadf4 — H2 headers
const BLUE: &str = "\x1b[38;2;138;173;244m";
/// Lavender #b7bdf8 — H3 headers
const LAVENDER: &str = "\x1b[38;2;183;189;248m";
/// Teal #8bd5ca — H4 headers
const TEAL: &str = "\x1b[38;2;139;213;202m";
/// Subtext1 #a5adcb — H5/H6 headers (dim)
const SUBTEXT1: &str = "\x1b[38;2;165;173;203m";
/// Sky #91d7e3 — bold text
const SKY: &str = "\x1b[38;2;145;215;227m";
/// Sapphire #7dc4e4 — italic text
const SAPPHIRE: &str = "\x1b[38;2;125;196;228m";
/// Peach #f5a97f — inline code
const PEACH: &str = "\x1b[38;2;245;169;127m";
/// Green #a6da95 — links
const GREEN: &str = "\x1b[38;2;166;218;149m";
/// Overlay1 #8087a2 — horizontal rules, muted elements
const OVERLAY1: &str = "\x1b[38;2;128;135;162m";
/// Reset all attributes
const RESET: &str = "\x1b[0m";
/// Types of markdown delimiters we track. /// Types of markdown delimiters we track.
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum DelimiterKind { enum DelimiterKind {
@@ -528,7 +557,7 @@ impl StreamingMarkdownFormatter {
|| (trimmed.len() >= 3 && trimmed.chars().all(|c| c == '_')); || (trimmed.len() >= 3 && trimmed.chars().all(|c| c == '_'));
if is_hr { if is_hr {
// Emit a horizontal rule // Emit a horizontal rule
self.pending_output.push_back("\x1b[2m────────────────────────────────────────\x1b[0m\n".to_string()); self.pending_output.push_back(format!("{}────────────────────────────────────────{}\n", OVERLAY1, RESET));
self.current_line.clear(); self.current_line.clear();
self.delimiter_stack.clear(); self.delimiter_stack.clear();
return; return;
@@ -569,12 +598,12 @@ impl StreamingMarkdownFormatter {
// Format based on level (magenta, bold for h1/h2) // Format based on level (magenta, bold for h1/h2)
// We wrap the already-formatted content in header color, then reset at the end // We wrap the already-formatted content in header color, then reset at the end
match level { match level {
1 => format!("\x1b[1;95m{}\x1b[0m\n", formatted_content), // Bold pink (Dracula) 1 => format!("\x1b[1m{}{}{}\n", MAUVE, formatted_content, RESET), // Bold Mauve
2 => format!("\x1b[35m{}\x1b[0m\n", formatted_content), // Purple/magenta (Dracula) 2 => format!("{}{}{}\n", BLUE, formatted_content, RESET), // Blue
3 => format!("\x1b[36m{}\x1b[0m\n", formatted_content), // Cyan (Dracula) 3 => format!("{}{}{}\n", LAVENDER, formatted_content, RESET), // Lavender
4 => format!("\x1b[37m{}\x1b[0m\n", formatted_content), // White (Dracula) 4 => format!("{}{}{}\n", TEAL, formatted_content, RESET), // Teal
5 => format!("\x1b[2m{}\x1b[0m\n", formatted_content), // Dim (Dracula) 5 => format!("\x1b[2m{}{}{}\n", SUBTEXT1, formatted_content, RESET), // Dim Subtext1
_ => format!("\x1b[2m{}\x1b[0m\n", formatted_content), // Dim for h6+ (Dracula) _ => format!("\x1b[2m{}{}{}\n", SUBTEXT1, formatted_content, RESET), // Dim Subtext1
} }
} }
@@ -607,14 +636,14 @@ impl StreamingMarkdownFormatter {
let text = &caps[1]; let text = &caps[1];
// Format any inline code within the link text // Format any inline code within the link text
let formatted_text = format_inline_code_only(text); let formatted_text = format_inline_code_only(text);
format!("\x1b[36;4m{}\x1b[0m", formatted_text) format!("\x1b[4m{}{}{}", GREEN, formatted_text, RESET)
}).to_string(); }).to_string();
// Process inline code `code` -> code (in orange) // Process inline code `code` -> code (in orange)
let code_re = regex::Regex::new(r"`([^`]+)`").unwrap(); let code_re = regex::Regex::new(r"`([^`]+)`").unwrap();
result = code_re.replace_all(&result, |caps: &regex::Captures| { result = code_re.replace_all(&result, |caps: &regex::Captures| {
let code = &caps[1]; let code = &caps[1];
format!("\x1b[38;2;216;177;114m{}\x1b[0m", code) format!("{}{}{}", PEACH, code, RESET)
}).to_string(); }).to_string();
// Handle unclosed inline code at end of line: `code without closing backtick // Handle unclosed inline code at end of line: `code without closing backtick
@@ -622,7 +651,7 @@ impl StreamingMarkdownFormatter {
let unclosed_code_re = regex::Regex::new(r"`([^`]+)$").unwrap(); let unclosed_code_re = regex::Regex::new(r"`([^`]+)$").unwrap();
result = unclosed_code_re.replace_all(&result, |caps: &regex::Captures| { result = unclosed_code_re.replace_all(&result, |caps: &regex::Captures| {
let code = &caps[1]; let code = &caps[1];
format!("\x1b[38;2;216;177;114m{}\x1b[0m", code) format!("{}{}{}", PEACH, code, RESET)
}).to_string(); }).to_string();
// Process strikethrough ~~text~~ -> text (with strikethrough) // Process strikethrough ~~text~~ -> text (with strikethrough)
@@ -645,7 +674,7 @@ impl StreamingMarkdownFormatter {
let text = &caps[1]; let text = &caps[1];
// Process nested italic within bold // Process nested italic within bold
let inner = format_nested_italic(text); let inner = format_nested_italic(text);
format!("\x1b[1;32m{}\x1b[0m", inner) format!("\x1b[1m{}{}{}", SKY, inner, RESET)
}).to_string(); }).to_string();
// Restore escaped characters (remove the placeholder markers) // Restore escaped characters (remove the placeholder markers)
@@ -668,7 +697,7 @@ impl StreamingMarkdownFormatter {
// Emit language label // Emit language label
if let Some(ref l) = lang { if let Some(ref l) = lang {
self.pending_output self.pending_output
.push_back(format!("\x1b[2;3m{}\x1b[0m\n", l)); .push_back(format!("\x1b[2;3m{}{}{}\n", OVERLAY1, l, RESET));
} }
// Highlight the code // Highlight the code
@@ -765,7 +794,7 @@ fn format_inline_code_only(text: &str) -> String {
let code_re = regex::Regex::new(r"`([^`]+)`").unwrap(); let code_re = regex::Regex::new(r"`([^`]+)`").unwrap();
code_re.replace_all(text, |caps: &regex::Captures| { code_re.replace_all(text, |caps: &regex::Captures| {
let code = &caps[1]; let code = &caps[1];
format!("\x1b[38;2;216;177;114m{}\x1b[0m", code) format!("{}{}{}", PEACH, code, RESET)
}).to_string() }).to_string()
} }
@@ -774,7 +803,7 @@ fn format_nested_italic(text: &str) -> String {
let italic_re = regex::Regex::new(r"\*([^*]+)\*").unwrap(); let italic_re = regex::Regex::new(r"\*([^*]+)\*").unwrap();
italic_re.replace_all(text, |caps: &regex::Captures| { italic_re.replace_all(text, |caps: &regex::Captures| {
let inner = &caps[1]; let inner = &caps[1];
format!("\x1b[3;36m{}\x1b[0m\x1b[1;32m", inner) // italic, then restore bold format!("\x1b[3m{}{}{}\x1b[1m{}", SAPPHIRE, inner, RESET, SKY) // italic sapphire, then restore bold sky
}).to_string() }).to_string()
} }
@@ -783,7 +812,7 @@ fn format_nested_bold(text: &str) -> String {
let bold_re = regex::Regex::new(r"\*\*(.+?)\*\*").unwrap(); let bold_re = regex::Regex::new(r"\*\*(.+?)\*\*").unwrap();
bold_re.replace_all(text, |caps: &regex::Captures| { bold_re.replace_all(text, |caps: &regex::Captures| {
let inner = &caps[1]; let inner = &caps[1];
format!("\x1b[1;32m{}\x1b[0m\x1b[3;36m", inner) // bold, then restore italic format!("\x1b[1m{}{}{}\x1b[3m{}", SKY, inner, RESET, SAPPHIRE) // bold sky, then restore italic sapphire
}).to_string() }).to_string()
} }
@@ -819,7 +848,7 @@ fn process_italic_with_nested_bold(text: &str) -> String {
let inner: String = chars[start..end_pos].iter().collect(); let inner: String = chars[start..end_pos].iter().collect();
// Process nested bold within the italic content // Process nested bold within the italic content
let formatted_inner = format_nested_bold(&inner); let formatted_inner = format_nested_bold(&inner);
result.push_str(&format!("\x1b[3;36m{}\x1b[0m", formatted_inner)); result.push_str(&format!("\x1b[3m{}{}{}", SAPPHIRE, formatted_inner, RESET));
i = end_pos + 1; i = end_pos + 1;
} else { } else {
// No closing *, just output the * // No closing *, just output the *
@@ -860,7 +889,7 @@ fn highlight_code(code: &str, lang: Option<&str>) -> String {
.and_then(|_| normalized_lang.and_then(|l| SYNTAX_SET.find_syntax_by_token(l))) .and_then(|_| normalized_lang.and_then(|l| SYNTAX_SET.find_syntax_by_token(l)))
.unwrap_or_else(|| SYNTAX_SET.find_syntax_plain_text()); .unwrap_or_else(|| SYNTAX_SET.find_syntax_plain_text());
let theme = &THEME_SET.themes["base16-ocean.dark"]; let theme = &THEME_SET.themes["base16-mocha.dark"];
let mut highlighter = HighlightLines::new(syntax, theme); let mut highlighter = HighlightLines::new(syntax, theme);
let mut output = String::new(); let mut output = String::new();

View File

@@ -562,8 +562,8 @@ fn test_bold_formatting() {
eprintln!("Input: {:?}", input); eprintln!("Input: {:?}", input);
eprintln!("Output: {:?}", full_output); eprintln!("Output: {:?}", full_output);
// Should contain green bold ANSI code (\x1b[1;32m) // Should contain sky bold ANSI code (Catppuccin Macchiato)
assert!(full_output.contains("\x1b[1;32m"), "Should contain bold formatting"); assert!(full_output.contains("\x1b[1m\x1b[38;2;145;215;227m"), "Should contain bold formatting");
// Should NOT contain raw ** // Should NOT contain raw **
assert!(!full_output.contains("**"), "Should not contain raw **"); assert!(!full_output.contains("**"), "Should not contain raw **");
} }
@@ -611,20 +611,20 @@ Normal text with **bold**, *italic*, and `inline code` all together.
eprintln!("=== END ==="); eprintln!("=== END ===");
// Check headers are formatted (Dracula colors) // Check headers are formatted (Dracula colors)
assert!(full_output.contains("\x1b[1;95mHeader 1"), "H1 should be bold pink"); assert!(full_output.contains("\x1b[1m\x1b[38;2;198;160;246mHeader 1"), "H1 should be bold mauve");
assert!(full_output.contains("\x1b[35mHeader 2"), "H2 should be magenta"); assert!(full_output.contains("\x1b[38;2;138;173;244mHeader 2"), "H2 should be blue");
// Check bold is green // Check bold is green
assert!(full_output.contains("\x1b[1;32mbold text\x1b[0m"), "Bold should be green"); assert!(full_output.contains("\x1b[1m\x1b[38;2;145;215;227mbold text\x1b[0m"), "Bold should be sky");
// Check italic is cyan // Check italic is cyan
assert!(full_output.contains("\x1b[3;36mitalic text\x1b[0m"), "Italic should be cyan"); assert!(full_output.contains("\x1b[3m\x1b[38;2;125;196;228mitalic text\x1b[0m"), "Italic should be sapphire");
// Check inline code is orange // Check inline code is orange
assert!(full_output.contains("\x1b[38;2;216;177;114minline code\x1b[0m"), "Inline code should be orange"); assert!(full_output.contains("\x1b[38;2;245;169;127minline code\x1b[0m"), "Inline code should be peach");
// Check link is cyan underlined // Check link is cyan underlined
assert!(full_output.contains("\x1b[36;4mlink\x1b[0m"), "Link should be cyan underlined"); assert!(full_output.contains("\x1b[4m\x1b[38;2;166;218;149mlink\x1b[0m"), "Link should be green underlined");
// Check bullets // Check bullets
assert!(full_output.contains("• Bullet item 1"), "Should have bullet"); assert!(full_output.contains("• Bullet item 1"), "Should have bullet");
@@ -662,7 +662,7 @@ fn test_unclosed_inline_code() {
assert!(!full_output.contains('`'), "Should not contain raw backtick"); assert!(!full_output.contains('`'), "Should not contain raw backtick");
// Should contain orange formatting for the unclosed code // Should contain orange formatting for the unclosed code
assert!(full_output.contains("\x1b[38;2;216;177;114m"), "Should have orange formatting"); assert!(full_output.contains("\x1b[38;2;245;169;127m"), "Should have peach formatting");
} }
#[test] #[test]
@@ -705,11 +705,11 @@ Your config already has it set up with consult:
// Headers should be formatted (H3 = cyan in Dracula), not raw // Headers should be formatted (H3 = cyan in Dracula), not raw
assert!(!full_output.contains("### Key"), "Should not have raw ### header"); assert!(!full_output.contains("### Key"), "Should not have raw ### header");
assert!(full_output.contains("\x1b[36mKey bindings"), "H3 header should be cyan"); assert!(full_output.contains("\x1b[38;2;183;189;248mKey bindings"), "H3 header should be lavender");
// Bold should be formatted, not raw // Bold should be formatted, not raw
assert!(!full_output.contains("**C-x p f**"), "Should not have raw ** bold"); assert!(!full_output.contains("**C-x p f**"), "Should not have raw ** bold");
assert!(full_output.contains("\x1b[1;32mC-x p f\x1b[0m"), "Bold should be green"); assert!(full_output.contains("\x1b[1m\x1b[38;2;145;215;227mC-x p f\x1b[0m"), "Bold should be sky");
} }
#[test] #[test]
@@ -823,7 +823,7 @@ Some **bold** text.
// Header should be formatted (H3 = cyan in Dracula) // Header should be formatted (H3 = cyan in Dracula)
assert!(!full_output.contains("### Header"), "Should not have raw ### header"); assert!(!full_output.contains("### Header"), "Should not have raw ### header");
assert!(full_output.contains("\x1b[36mHeader after table"), "H3 header should be cyan"); assert!(full_output.contains("\x1b[38;2;183;189;248mHeader after table"), "H3 header should be lavender");
// Bold should be formatted // Bold should be formatted
assert!(!full_output.contains("**bold**"), "Should not have raw ** bold"); assert!(!full_output.contains("**bold**"), "Should not have raw ** bold");
@@ -1011,7 +1011,7 @@ fn test_simple_italic() {
let mut fmt = make_formatter(); let mut fmt = make_formatter();
let out = fmt.process("*simple italic*\n"); let out = fmt.process("*simple italic*\n");
eprintln!("Simple italic: {:?}", out); eprintln!("Simple italic: {:?}", out);
assert!(out.contains("\x1b[3;36m"), "Should have italic formatting"); assert!(out.contains("\x1b[3m\x1b[38;2;125;196;228m"), "Should have italic formatting");
} }
#[test] #[test]
@@ -1020,9 +1020,9 @@ fn test_italic_with_nested_bold() {
let output = fmt.process("*italic with **nested bold** inside*\n"); let output = fmt.process("*italic with **nested bold** inside*\n");
eprintln!("Output: {:?}", output); eprintln!("Output: {:?}", output);
// Should have italic formatting (cyan) // Should have italic formatting (cyan)
assert!(output.contains("\x1b[3;36m"), "Should have italic formatting"); assert!(output.contains("\x1b[3m\x1b[38;2;125;196;228m"), "Should have italic formatting");
// Should have bold formatting (green) for nested bold // Should have bold formatting (green) for nested bold
assert!(output.contains("\x1b[1;32m"), "Should have bold formatting for nested"); assert!(output.contains("\x1b[1m\x1b[38;2;145;215;227m"), "Should have bold formatting for nested");
} }
// ============================================================================= // =============================================================================
@@ -1790,10 +1790,10 @@ fn test_bold_inside_header() {
assert!(!full.contains("**"), "Should not contain raw ** markers, got: {}", full); assert!(!full.contains("**"), "Should not contain raw ** markers, got: {}", full);
// Should have header formatting (H1 = bold pink in Dracula) // Should have header formatting (H1 = bold pink in Dracula)
assert!(full.contains("\x1b[1;95m"), "Should have bold pink header formatting"); assert!(full.contains("\x1b[1m\x1b[38;2;198;160;246m"), "Should have bold mauve header formatting");
// Should have bold formatting (green) for the bold text inside // Should have bold formatting (green) for the bold text inside
assert!(full.contains("\x1b[1;32m"), "Should have green bold formatting for **Bold Header**"); assert!(full.contains("\x1b[1m\x1b[38;2;145;215;227m"), "Should have sky bold formatting for **Bold Header**");
} }
#[test] #[test]
@@ -1817,10 +1817,10 @@ fn test_italic_inside_header() {
assert!(!without_ansi.contains('*'), "Should not contain raw * markers, got: {}", without_ansi); assert!(!without_ansi.contains('*'), "Should not contain raw * markers, got: {}", without_ansi);
// Should have header formatting (magenta) // Should have header formatting (magenta)
assert!(full.contains("\x1b[35m"), "Should have magenta header formatting"); assert!(full.contains("\x1b[38;2;138;173;244m"), "Should have blue header formatting");
// Should have italic formatting (cyan) for the italic text inside // Should have italic formatting (cyan) for the italic text inside
assert!(full.contains("\x1b[3;36m"), "Should have cyan italic formatting for *Italic Header*"); assert!(full.contains("\x1b[3m\x1b[38;2;125;196;228m"), "Should have sapphire italic formatting for *Italic Header*");
} }
#[test] #[test]
@@ -1842,11 +1842,11 @@ fn test_code_inside_header() {
let without_ansi = strip_ansi(&full); let without_ansi = strip_ansi(&full);
assert!(!without_ansi.contains('`'), "Should not contain raw backticks, got: {}", without_ansi); assert!(!without_ansi.contains('`'), "Should not contain raw backticks, got: {}", without_ansi);
// Should have header formatting (H3 = cyan in Dracula) // Should have header formatting (H3 = lavender in Catppuccin Macchiato)
assert!(full.contains("\x1b[36m"), "Should have cyan header formatting"); assert!(full.contains("\x1b[38;2;183;189;248m"), "Should have lavender header formatting");
// Should have code formatting (orange) for the inline code // Should have code formatting (orange) for the inline code
assert!(full.contains("\x1b[38;2;216;177;114m"), "Should have orange code formatting"); assert!(full.contains("\x1b[38;2;245;169;127m"), "Should have peach code formatting");
} }
#[test] #[test]
@@ -1870,8 +1870,8 @@ fn test_mixed_formatting_inside_header() {
assert!(!without_ansi.contains("*italic*"), "Should not contain raw *italic* markers"); assert!(!without_ansi.contains("*italic*"), "Should not contain raw *italic* markers");
// Should have both bold and italic formatting // Should have both bold and italic formatting
assert!(full.contains("\x1b[1;32m"), "Should have green bold formatting"); assert!(full.contains("\x1b[1m\x1b[38;2;145;215;227m"), "Should have sky bold formatting");
assert!(full.contains("\x1b[3;36m"), "Should have cyan italic formatting"); assert!(full.contains("\x1b[3m\x1b[38;2;125;196;228m"), "Should have sapphire italic formatting");
} }
/// Helper to strip ANSI escape codes for easier assertion /// Helper to strip ANSI escape codes for easier assertion